VUE дизайн
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,6 +13,7 @@ front_vue/node_modules/
|
|||||||
front_vue/dist/
|
front_vue/dist/
|
||||||
front_vue/src/auto-imports.d.ts
|
front_vue/src/auto-imports.d.ts
|
||||||
front_vue/src/components.d.ts
|
front_vue/src/components.d.ts
|
||||||
|
wailsjs
|
||||||
|
|
||||||
# Cursor
|
# Cursor
|
||||||
.cursorrules
|
.cursorrules
|
||||||
|
|||||||
394
Backend/admin/API.md
Normal file
394
Backend/admin/API.md
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
# 📡 vServer Admin API
|
||||||
|
|
||||||
|
API панели управления vServer. Все методы вызываются через Wails IPC биндинги.
|
||||||
|
|
||||||
|
> **Доступ из фронтенда:** `window.go.admin.App.MethodName()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Содержание
|
||||||
|
|
||||||
|
- [Сервисы](#-сервисы)
|
||||||
|
- [Сайты](#-сайты)
|
||||||
|
- [Прокси](#-прокси)
|
||||||
|
- [Сертификаты](#-сертификаты)
|
||||||
|
- [Конфигурация](#-конфигурация)
|
||||||
|
- [vAccess](#-vaccess)
|
||||||
|
- [Типы данных](#-типы-данных)
|
||||||
|
- [События](#-события)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Сервисы
|
||||||
|
|
||||||
|
### `GetAllServicesStatus()`
|
||||||
|
Возвращает статусы всех сервисов.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `AllServicesStatus`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"http": { "name": "HTTP", "status": true, "port": "80", "info": "" },
|
||||||
|
"https": { "name": "HTTPS", "status": true, "port": "443", "info": "" },
|
||||||
|
"mysql": { "name": "MySQL", "status": true, "port": "3306","info": "" },
|
||||||
|
"php": { "name": "PHP", "status": true, "port": "8000","info": "" },
|
||||||
|
"proxy": { "name": "Proxy", "status": false, "port": "", "info": "" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `CheckServicesReady()`
|
||||||
|
Проверяет, готовы ли все основные сервисы (HTTP, HTTPS, MySQL, PHP).
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `bool`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `StartServer()`
|
||||||
|
Запускает все сервисы (HTTP, HTTPS, PHP, MySQL, SSL).
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `string` — `"Server started"`
|
||||||
|
|
||||||
|
### `StopServer()`
|
||||||
|
Останавливает все сервисы.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `string` — `"Server stopped"`
|
||||||
|
|
||||||
|
### `RestartAllServices()`
|
||||||
|
Перезапускает все сервисы с перезагрузкой конфига и сертификатов.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `string` — `"All services restarted"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `StartHTTPService()` / `StopHTTPService()`
|
||||||
|
Управление HTTP сервером (порт 80).
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `string` — `"HTTP started"` / `"HTTP stopped"`
|
||||||
|
|
||||||
|
### `StartHTTPSService()` / `StopHTTPSService()`
|
||||||
|
Управление HTTPS сервером (порт 443).
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `string` — `"HTTPS started"` / `"HTTPS stopped"`
|
||||||
|
|
||||||
|
### `StartMySQLService()` / `StopMySQLService()`
|
||||||
|
Управление MySQL сервером.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `string` — `"MySQL started"` / `"MySQL stopped"`
|
||||||
|
|
||||||
|
### `StartPHPService()` / `StopPHPService()`
|
||||||
|
Управление PHP FastCGI пулом.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `string` — `"PHP started"` / `"PHP stopped"`
|
||||||
|
|
||||||
|
### `EnableProxyService()` / `DisableProxyService()`
|
||||||
|
Включение/отключение прокси-сервиса. Сохраняет изменение в конфиг.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `string` — `"Proxy enabled"` / `"Proxy disabled"`
|
||||||
|
|
||||||
|
### `EnableACMEService()` / `DisableACMEService()`
|
||||||
|
Включение/отключение ACME (Let's Encrypt). Сохраняет изменение в конфиг.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `string` — `"ACME enabled"` / `"ACME disabled"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Сайты
|
||||||
|
|
||||||
|
### `GetSitesList()`
|
||||||
|
Получает список всех сайтов.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `[]SiteInfo`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "My Site",
|
||||||
|
"host": "example.com",
|
||||||
|
"alias": ["www.example.com"],
|
||||||
|
"status": "active",
|
||||||
|
"root_file": "index.html",
|
||||||
|
"root_file_routing": false,
|
||||||
|
"auto_create_ssl": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `CreateNewSite(siteJSON)`
|
||||||
|
Создаёт новый сайт. Создаёт папку, конфиг и шаблон index.html.
|
||||||
|
|
||||||
|
- **Параметры:** `siteJSON: string` — JSON строка с данными `SiteInfo`
|
||||||
|
- **Возвращает:** `string` — `"Site created successfully"` или `"Error: ..."`
|
||||||
|
|
||||||
|
### `DeleteSite(host)`
|
||||||
|
Удаляет сайт (папку и запись из конфига).
|
||||||
|
|
||||||
|
- **Параметры:** `host: string` — домен сайта
|
||||||
|
- **Возвращает:** `string` — `"Site deleted successfully"` или `"Error: ..."`
|
||||||
|
|
||||||
|
### `UpdateSiteCache()`
|
||||||
|
Обновляет кэш статусов сайтов.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `string` — `"Cache updated"`
|
||||||
|
|
||||||
|
### `OpenSiteFolder(host)`
|
||||||
|
Открывает папку сайта в проводнике Windows.
|
||||||
|
|
||||||
|
- **Параметры:** `host: string` — домен сайта
|
||||||
|
- **Возвращает:** `string` — `"Folder opened"` или `"Error: ..."`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔀 Прокси
|
||||||
|
|
||||||
|
### `GetProxyList()`
|
||||||
|
Получает список всех прокси-сервисов.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `[]ProxyInfo`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"enable": true,
|
||||||
|
"external_domain": "app.example.com",
|
||||||
|
"local_address": "127.0.0.1",
|
||||||
|
"local_port": "3000",
|
||||||
|
"service_https_use": false,
|
||||||
|
"auto_https": true,
|
||||||
|
"auto_create_ssl": true,
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Сертификаты
|
||||||
|
|
||||||
|
### `GetCertInfo(domain)`
|
||||||
|
Получает информацию о сертификате для домена.
|
||||||
|
|
||||||
|
- **Параметры:** `domain: string`
|
||||||
|
- **Возвращает:** `CertInfo`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"domain": "example.com",
|
||||||
|
"issuer": "Let's Encrypt",
|
||||||
|
"not_before": "2025-01-01T00:00:00Z",
|
||||||
|
"not_after": "2025-03-31T00:00:00Z",
|
||||||
|
"days_left": 60,
|
||||||
|
"is_expired": false,
|
||||||
|
"has_cert": true,
|
||||||
|
"dns_names": ["example.com", "www.example.com"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GetAllCertsInfo()`
|
||||||
|
Получает информацию о всех сертификатах.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `[]CertInfo`
|
||||||
|
|
||||||
|
### `ObtainSSLCertificate(domain)`
|
||||||
|
Получает SSL сертификат через Let's Encrypt для указанного домена.
|
||||||
|
|
||||||
|
- **Параметры:** `domain: string`
|
||||||
|
- **Возвращает:** `string` — `"SSL certificate obtained successfully for ..."` или `"Error: ..."`
|
||||||
|
|
||||||
|
### `ObtainAllSSLCertificates()`
|
||||||
|
Получает сертификаты для всех доменов с флагом `auto_create_ssl: true`.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `string` — `"Completed: X success, Y errors"`
|
||||||
|
|
||||||
|
### `UploadCertificate(host, certType, certDataBase64)`
|
||||||
|
Загружает сертификат вручную.
|
||||||
|
|
||||||
|
- **Параметры:**
|
||||||
|
- `host: string` — домен
|
||||||
|
- `certType: string` — тип файла (`"cert"` или `"key"`)
|
||||||
|
- `certDataBase64: string` — содержимое файла в Base64
|
||||||
|
- **Возвращает:** `string` — `"Certificate uploaded successfully"` или `"Error: ..."`
|
||||||
|
|
||||||
|
### `DeleteCertificate(domain)`
|
||||||
|
Удаляет сертификат для домена.
|
||||||
|
|
||||||
|
- **Параметры:** `domain: string`
|
||||||
|
- **Возвращает:** `string` — `"Certificate deleted successfully"` или `"Error: ..."`
|
||||||
|
|
||||||
|
### `ReloadSSLCertificates()`
|
||||||
|
Перезагружает все SSL сертификаты.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `string` — `"SSL certificates reloaded"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Конфигурация
|
||||||
|
|
||||||
|
### `GetConfig()`
|
||||||
|
Возвращает текущую конфигурацию сервера.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `object` — полный объект конфигурации
|
||||||
|
|
||||||
|
### `SaveConfig(configJSON)`
|
||||||
|
Сохраняет конфигурацию в файл и перезагружает.
|
||||||
|
|
||||||
|
- **Параметры:** `configJSON: string` — JSON строка с конфигурацией
|
||||||
|
- **Возвращает:** `string` — `"Config saved"` или `"Error: ..."`
|
||||||
|
|
||||||
|
### `ReloadConfig()`
|
||||||
|
Перезагружает конфигурацию из файла.
|
||||||
|
|
||||||
|
- **Параметры:** нет
|
||||||
|
- **Возвращает:** `string` — `"Config reloaded"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ vAccess
|
||||||
|
|
||||||
|
### `GetVAccessRules(host, isProxy)`
|
||||||
|
Получает правила доступа для сайта или прокси.
|
||||||
|
|
||||||
|
- **Параметры:**
|
||||||
|
- `host: string` — домен
|
||||||
|
- `isProxy: bool` — `true` для прокси, `false` для сайта
|
||||||
|
- **Возвращает:** `VAccessConfig`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"type": "allow",
|
||||||
|
"type_file": [".php", ".html"],
|
||||||
|
"path_access": ["/admin"],
|
||||||
|
"ip_list": ["192.168.1.0/24"],
|
||||||
|
"exceptions_dir": ["/public"],
|
||||||
|
"url_error": "/403.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `SaveVAccessRules(host, isProxy, configJSON)`
|
||||||
|
Сохраняет правила доступа.
|
||||||
|
|
||||||
|
- **Параметры:**
|
||||||
|
- `host: string` — домен
|
||||||
|
- `isProxy: bool` — `true` для прокси, `false` для сайта
|
||||||
|
- `configJSON: string` — JSON строка с `VAccessConfig`
|
||||||
|
- **Возвращает:** `string` — `"vAccess saved"` или `"Error: ..."`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Типы данных
|
||||||
|
|
||||||
|
### `ServiceStatus`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "string",
|
||||||
|
"status": "bool",
|
||||||
|
"port": "string",
|
||||||
|
"info": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `AllServicesStatus`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"http": "ServiceStatus",
|
||||||
|
"https": "ServiceStatus",
|
||||||
|
"mysql": "ServiceStatus",
|
||||||
|
"php": "ServiceStatus",
|
||||||
|
"proxy": "ServiceStatus"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `SiteInfo`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "string",
|
||||||
|
"host": "string",
|
||||||
|
"alias": ["string"],
|
||||||
|
"status": "string",
|
||||||
|
"root_file": "string",
|
||||||
|
"root_file_routing": "bool",
|
||||||
|
"auto_create_ssl": "bool"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ProxyInfo`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enable": "bool",
|
||||||
|
"external_domain": "string",
|
||||||
|
"local_address": "string",
|
||||||
|
"local_port": "string",
|
||||||
|
"service_https_use": "bool",
|
||||||
|
"auto_https": "bool",
|
||||||
|
"auto_create_ssl": "bool",
|
||||||
|
"status": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `CertInfo`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"domain": "string",
|
||||||
|
"issuer": "string",
|
||||||
|
"not_before": "string",
|
||||||
|
"not_after": "string",
|
||||||
|
"days_left": "int",
|
||||||
|
"is_expired": "bool",
|
||||||
|
"has_cert": "bool",
|
||||||
|
"dns_names": ["string"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `VAccessRule`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"type_file": ["string"],
|
||||||
|
"path_access": ["string"],
|
||||||
|
"ip_list": ["string"],
|
||||||
|
"exceptions_dir": ["string"],
|
||||||
|
"url_error": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `VAccessConfig`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rules": ["VAccessRule"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📡 События (Wails Events)
|
||||||
|
|
||||||
|
### `service:changed`
|
||||||
|
Эмитится каждые 500мс с текущими статусами сервисов.
|
||||||
|
|
||||||
|
- **Данные:** `AllServicesStatus`
|
||||||
|
|
||||||
|
### `server:already_running`
|
||||||
|
Эмитится при запуске, если vServer уже запущен в другом экземпляре.
|
||||||
|
|
||||||
|
- **Данные:** `bool` — `true`
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Base Styles & Reset
|
|
||||||
Базовые стили приложения
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.6;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
/* Градиентный фон с размытием */
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: fixed;
|
|
||||||
top: -50%;
|
|
||||||
left: -50%;
|
|
||||||
width: 200%;
|
|
||||||
height: 200%;
|
|
||||||
/* background: radial-gradient(circle at 80% 80%, rgba(138, 92, 246, 0.116) 0%, transparent 50%); */
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
code {
|
|
||||||
background: rgba(139, 92, 246, 0.15);
|
|
||||||
backdrop-filter: var(--backdrop-blur-light);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar Styling */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: rgba(139, 92, 246, 0.05);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(139, 92, 246, 0.3);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(139, 92, 246, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bounce {
|
|
||||||
0%, 100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utility Classes */
|
|
||||||
.hidden {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-in {
|
|
||||||
animation: fadeIn var(--transition-slow);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Badges Component
|
|
||||||
Единая система бейджей
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Base Badge */
|
|
||||||
.badge {
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--font-bold);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
backdrop-filter: var(--backdrop-blur-light);
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Badges */
|
|
||||||
.badge-online {
|
|
||||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.25), rgba(16, 185, 129, 0.15));
|
|
||||||
color: var(--accent-green);
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-offline {
|
|
||||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.25), rgba(239, 68, 68, 0.15));
|
|
||||||
color: var(--accent-red);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-pending {
|
|
||||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.25), rgba(245, 158, 11, 0.15));
|
|
||||||
color: var(--accent-yellow);
|
|
||||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Yes/No Badges */
|
|
||||||
.badge-yes {
|
|
||||||
background: rgba(34, 197, 94, 0.2);
|
|
||||||
color: var(--accent-green);
|
|
||||||
border: 1px solid var(--accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-no {
|
|
||||||
background: rgba(100, 116, 139, 0.2);
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: 1px solid var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Indicator (Dot) */
|
|
||||||
.status-indicator {
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-online {
|
|
||||||
background: var(--accent-green);
|
|
||||||
color: var(--accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-offline {
|
|
||||||
background: var(--accent-red);
|
|
||||||
color: var(--accent-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mini Tags (для таблиц) */
|
|
||||||
.mini-tag {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: rgba(139, 92, 246, 0.15);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
margin: 2px;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(139, 92, 246, 0.25);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Certificate Icons */
|
|
||||||
.cert-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
margin-right: 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-valid {
|
|
||||||
background: rgba(16, 185, 129, 0.2);
|
|
||||||
color: var(--accent-green);
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-expired {
|
|
||||||
background: rgba(239, 68, 68, 0.2);
|
|
||||||
color: var(--accent-red);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Buttons Component
|
|
||||||
Единая система кнопок
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Base Button Styles */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:disabled):active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action Button - Основная кнопка действия */
|
|
||||||
.action-btn {
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
background: rgba(139, 92, 246, 0.15);
|
|
||||||
backdrop-filter: var(--backdrop-blur-light);
|
|
||||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: rgba(139, 92, 246, 0.25);
|
|
||||||
border-color: rgba(139, 92, 246, 0.5);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Save Button Variant */
|
|
||||||
.save-btn {
|
|
||||||
background: rgba(16, 185, 129, 0.15);
|
|
||||||
border-color: rgba(16, 185, 129, 0.3);
|
|
||||||
color: var(--accent-green);
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: rgba(16, 185, 129, 0.25);
|
|
||||||
border-color: rgba(16, 185, 129, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Delete Button Variant */
|
|
||||||
.delete-btn {
|
|
||||||
background: rgba(239, 68, 68, 0.15);
|
|
||||||
border-color: rgba(239, 68, 68, 0.3);
|
|
||||||
color: var(--accent-red);
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: rgba(239, 68, 68, 0.25);
|
|
||||||
border-color: rgba(239, 68, 68, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Icon Button - Квадратная кнопка с иконкой */
|
|
||||||
.icon-btn {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
background: rgba(139, 92, 246, 0.1);
|
|
||||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
font-size: var(--text-md);
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: rgba(139, 92, 246, 0.25);
|
|
||||||
border-color: rgba(139, 92, 246, 0.5);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Small Icon Button */
|
|
||||||
.icon-btn-small {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
background: rgba(239, 68, 68, 0.1);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--accent-red);
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: rgba(239, 68, 68, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Window Control Buttons */
|
|
||||||
.window-btn {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:hover {
|
|
||||||
background: var(--accent-red);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Server Control Button */
|
|
||||||
.server-control-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
padding: var(--space-sm) 18px;
|
|
||||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(239, 68, 68, 0.1));
|
|
||||||
backdrop-filter: var(--backdrop-blur-light);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
|
||||||
border-radius: 20px;
|
|
||||||
color: var(--accent-red);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.3), rgba(239, 68, 68, 0.15));
|
|
||||||
border-color: rgba(239, 68, 68, 0.6);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-control-btn.start-mode {
|
|
||||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(16, 185, 129, 0.1));
|
|
||||||
border-color: rgba(16, 185, 129, 0.4);
|
|
||||||
color: var(--accent-green);
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.3), rgba(16, 185, 129, 0.15));
|
|
||||||
border-color: rgba(16, 185, 129, 0.6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Toggle Buttons */
|
|
||||||
.status-toggle {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 10px var(--space-md);
|
|
||||||
background: rgba(100, 116, 139, 0.1);
|
|
||||||
border: 1px solid rgba(100, 116, 139, 0.3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(100, 116, 139, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: rgba(16, 185, 129, 0.2);
|
|
||||||
border-color: rgba(16, 185, 129, 0.5);
|
|
||||||
color: var(--accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child.active {
|
|
||||||
background: rgba(239, 68, 68, 0.2);
|
|
||||||
border-color: rgba(239, 68, 68, 0.5);
|
|
||||||
color: var(--accent-red);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation Buttons */
|
|
||||||
.nav-item {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -16px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 3px;
|
|
||||||
height: 0;
|
|
||||||
background: linear-gradient(180deg, var(--accent-purple), var(--accent-purple-light));
|
|
||||||
border-radius: 0 2px 2px 0;
|
|
||||||
transition: height var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(139, 92, 246, 0.1);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: rgba(139, 92, 246, 0.15);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Breadcrumb Buttons */
|
|
||||||
.breadcrumb-item {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: var(--space-sm) var(--space-lg);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(139, 92, 246, 0.1);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: var(--font-medium);
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tab Buttons */
|
|
||||||
.vaccess-tab {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 10px 18px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--font-medium);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(139, 92, 246, 0.1);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: var(--accent-purple);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Cards Component
|
|
||||||
Единая система карточек
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Service Card */
|
|
||||||
.service-card {
|
|
||||||
background: var(--glass-bg-light);
|
|
||||||
backdrop-filter: var(--backdrop-blur);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
padding: var(--space-lg);
|
|
||||||
transition: all var(--transition-bounce);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
/* Градиентная линия сверху */
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 3px;
|
|
||||||
background: linear-gradient(90deg, var(--accent-purple), var(--accent-purple-light), var(--accent-cyan));
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--transition-slow);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: var(--shadow-purple);
|
|
||||||
border-color: var(--glass-border-hover);
|
|
||||||
background: rgba(20, 20, 40, 0.5);
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-name {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
|
|
||||||
i {
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: var(--font-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Settings Card */
|
|
||||||
.settings-card {
|
|
||||||
background: var(--glass-bg-light);
|
|
||||||
backdrop-filter: var(--backdrop-blur);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-card-title {
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
|
|
||||||
i {
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* vAccess Rule Card */
|
|
||||||
.vaccess-rule-card {
|
|
||||||
background: rgba(10, 14, 26, 0.4);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
padding-bottom: 12px;
|
|
||||||
border-bottom: 1px solid var(--glass-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-number {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
font-weight: var(--font-bold);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-content {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 20px;
|
|
||||||
|
|
||||||
> .form-group:first-child {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Help Cards */
|
|
||||||
.help-card {
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: var(--space-xl);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
transition: all var(--transition-slow);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--glass-border-hover);
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: var(--text-2xl);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
margin: 0 0 20px 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
padding: 12px 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
border-bottom: 1px solid rgba(139, 92, 246, 0.05);
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-examples {
|
|
||||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.05) 0%, rgba(99, 102, 241, 0.05) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Forms Component
|
|
||||||
Единая система форм
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Form Container */
|
|
||||||
.settings-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Group */
|
|
||||||
.form-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Row (2 columns) */
|
|
||||||
.form-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Row (3 columns) */
|
|
||||||
.form-row.form-row-3 {
|
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Label */
|
|
||||||
.form-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-hint {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: var(--font-normal);
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Input */
|
|
||||||
.form-input {
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: var(--glass-bg-dark);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
outline: none;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: rgba(139, 92, 246, 0.5);
|
|
||||||
box-shadow: 0 0 12px rgba(139, 92, 246, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: var(--text-muted);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Info */
|
|
||||||
.form-info {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
padding: 12px;
|
|
||||||
background: rgba(139, 92, 246, 0.05);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border-left: 3px solid var(--accent-purple);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle Switch */
|
|
||||||
.toggle-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 50px;
|
|
||||||
height: 26px;
|
|
||||||
|
|
||||||
input {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
|
|
||||||
&:checked + .toggle-slider {
|
|
||||||
background: rgba(16, 185, 129, 0.2);
|
|
||||||
border-color: rgba(16, 185, 129, 0.5);
|
|
||||||
box-shadow: 0 0 16px rgba(16, 185, 129, 0.3);
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
transform: translateX(24px);
|
|
||||||
background: var(--accent-green);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-slider {
|
|
||||||
position: absolute;
|
|
||||||
cursor: pointer;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(239, 68, 68, 0.2);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
|
||||||
transition: all var(--transition-slow);
|
|
||||||
border-radius: 26px;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
position: absolute;
|
|
||||||
content: "";
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
left: 3px;
|
|
||||||
bottom: 3px;
|
|
||||||
background: rgba(239, 68, 68, 0.8);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
transition: all var(--transition-slow);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-label {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Checkbox */
|
|
||||||
.checkbox-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tags Container */
|
|
||||||
.tags-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--glass-bg-dark);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
min-height: 48px;
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: rgba(139, 92, 246, 0.2);
|
|
||||||
border: 1px solid rgba(139, 92, 246, 0.4);
|
|
||||||
border-radius: 16px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: var(--font-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-remove {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--accent-red);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(239, 68, 68, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-input-wrapper {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Field Editor */
|
|
||||||
.field-editor {
|
|
||||||
padding: 20px;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: var(--text-xl);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Modals Component
|
|
||||||
Единая система модальных окон
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Modal Overlay */
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
backdrop-filter: var(--backdrop-blur-light);
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity var(--transition-slow);
|
|
||||||
|
|
||||||
&.show {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
|
|
||||||
.modal-window {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-content {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal Window */
|
|
||||||
.modal-window {
|
|
||||||
background: rgba(20, 20, 40, 0.95);
|
|
||||||
backdrop-filter: var(--backdrop-blur);
|
|
||||||
border: 1px solid var(--glass-border-hover);
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
min-width: 900px;
|
|
||||||
max-width: 1200px;
|
|
||||||
max-height: 85vh;
|
|
||||||
overflow: hidden;
|
|
||||||
transform: scale(0.9);
|
|
||||||
transition: transform var(--transition-slow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 20px var(--space-lg);
|
|
||||||
border-bottom: 1px solid var(--glass-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
font-size: var(--text-xl);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close-btn {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(239, 68, 68, 0.2);
|
|
||||||
color: var(--accent-red);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
padding: var(--space-lg);
|
|
||||||
max-height: 60vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 20px var(--space-lg);
|
|
||||||
border-top: 1px solid var(--glass-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer .action-btn {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Notification Modal */
|
|
||||||
.notification {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
backdrop-filter: var(--backdrop-blur-light);
|
|
||||||
z-index: var(--z-notification);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity var(--transition-slow);
|
|
||||||
|
|
||||||
&.show {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-content {
|
|
||||||
min-width: 400px;
|
|
||||||
max-width: 500px;
|
|
||||||
padding: var(--space-xl) 40px;
|
|
||||||
background: rgba(20, 20, 40, 0.95);
|
|
||||||
backdrop-filter: var(--backdrop-blur);
|
|
||||||
border: 1px solid var(--glass-border-hover);
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
box-shadow: var(--shadow-lg), 0 0 40px rgba(139, 92, 246, 0.3);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
text-align: center;
|
|
||||||
transform: scale(0.9);
|
|
||||||
transition: transform var(--transition-bounce);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification.success .notification-content {
|
|
||||||
border-color: rgba(16, 185, 129, 0.4);
|
|
||||||
box-shadow: var(--shadow-lg), 0 0 40px rgba(16, 185, 129, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification.error .notification-content {
|
|
||||||
border-color: rgba(239, 68, 68, 0.4);
|
|
||||||
box-shadow: var(--shadow-lg), 0 0 40px rgba(239, 68, 68, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-icon {
|
|
||||||
font-size: 56px;
|
|
||||||
|
|
||||||
i {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification.success .notification-icon {
|
|
||||||
color: var(--accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification.error .notification-icon {
|
|
||||||
color: var(--accent-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-text {
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* App Loader */
|
|
||||||
.app-loader {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 50%, var(--bg-tertiary) 100%);
|
|
||||||
z-index: var(--z-loader);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.5s ease;
|
|
||||||
|
|
||||||
&.hide {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader-icon {
|
|
||||||
font-size: 64px;
|
|
||||||
animation: bounce 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader-text {
|
|
||||||
font-size: var(--text-xl);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader-spinner {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: 3px solid rgba(139, 92, 246, 0.2);
|
|
||||||
border-top-color: var(--accent-purple);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Tables Component
|
|
||||||
ЕДИНАЯ система таблиц для всего приложения
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Table Container */
|
|
||||||
.table-container {
|
|
||||||
background: var(--glass-bg-light);
|
|
||||||
backdrop-filter: var(--backdrop-blur);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: var(--radius-2xl);
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base Table */
|
|
||||||
.data-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
|
|
||||||
thead {
|
|
||||||
background: rgba(139, 92, 246, 0.12);
|
|
||||||
backdrop-filter: var(--backdrop-blur-light);
|
|
||||||
|
|
||||||
tr {
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
padding: 18px 20px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--font-bold);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1.2px;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
width: 120px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody {
|
|
||||||
tr {
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(139, 92, 246, 0.08);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 16px 20px;
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--text-primary);
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* vAccess Table - использует те же стили что и data-table */
|
|
||||||
.vaccess-table-container {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
max-height: 55vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vaccess-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
|
|
||||||
thead {
|
|
||||||
tr {
|
|
||||||
background: rgba(139, 92, 246, 0.05);
|
|
||||||
display: table-row;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
padding: 16px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
border: none;
|
|
||||||
display: table-cell;
|
|
||||||
box-sizing: content-box;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody {
|
|
||||||
tr {
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
transition: all var(--transition-slow);
|
|
||||||
cursor: grab;
|
|
||||||
display: table-row;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(139, 92, 246, 0.08);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[draggable="true"] {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 20px 16px;
|
|
||||||
font-size: var(--text-md);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-top: 1px solid rgba(139, 92, 246, 0.05);
|
|
||||||
border-bottom: 1px solid rgba(139, 92, 246, 0.05);
|
|
||||||
cursor: pointer;
|
|
||||||
display: table-cell;
|
|
||||||
box-sizing: content-box;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Принудительно растягиваем на всю ширину */
|
|
||||||
thead, tbody {
|
|
||||||
display: table-row-group;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table Column Sizing - адаптивные размеры */
|
|
||||||
.col-drag {
|
|
||||||
width: 3%;
|
|
||||||
min-width: 40px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-type {
|
|
||||||
width: 8%;
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-files {
|
|
||||||
width: 15%;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-paths {
|
|
||||||
width: 18%;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-ips {
|
|
||||||
width: 15%;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-exceptions {
|
|
||||||
width: 15%;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-error {
|
|
||||||
width: 10%;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-actions {
|
|
||||||
width: 5%;
|
|
||||||
min-width: 60px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Drag Handle */
|
|
||||||
.drag-handle {
|
|
||||||
color: var(--text-muted);
|
|
||||||
opacity: 0.3;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
cursor: grab;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty Field */
|
|
||||||
.empty-field {
|
|
||||||
color: var(--text-muted);
|
|
||||||
opacity: 0.4;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Clickable Link in Tables */
|
|
||||||
.clickable-link {
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--accent-purple);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Table */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.table-container {
|
|
||||||
overflow-x: scroll;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Container Layout
|
|
||||||
Основной контейнер контента
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.container {
|
|
||||||
height: calc(100vh - var(--header-height));
|
|
||||||
margin-top: var(--header-height);
|
|
||||||
margin-left: var(--sidebar-width);
|
|
||||||
padding: 40px var(--space-3xl);
|
|
||||||
position: relative;
|
|
||||||
z-index: var(--z-base);
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main Content */
|
|
||||||
.main-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section */
|
|
||||||
.section {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
font-weight: var(--font-bold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1.5px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-lg);
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
width: 4px;
|
|
||||||
height: 16px;
|
|
||||||
background: linear-gradient(180deg, var(--accent-purple), var(--accent-purple-light));
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.footer {
|
|
||||||
margin-top: var(--space-lg);
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.header {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Header Layout
|
|
||||||
Window controls и title bar
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.window-controls {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
background: rgba(10, 14, 26, 0.9);
|
|
||||||
backdrop-filter: var(--backdrop-blur);
|
|
||||||
border-bottom: 1px solid var(--glass-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
height: var(--header-height);
|
|
||||||
padding: 0 var(--space-xl);
|
|
||||||
--wails-draggable: drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-bar-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-icon {
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-text {
|
|
||||||
font-size: var(--text-xl);
|
|
||||||
font-weight: var(--font-bold);
|
|
||||||
color: #ffffff;
|
|
||||||
user-select: none;
|
|
||||||
letter-spacing: -0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-bar-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-md);
|
|
||||||
--wails-draggable: no-drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Server Status */
|
|
||||||
.server-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(16, 185, 129, 0.05));
|
|
||||||
backdrop-filter: var(--backdrop-blur-light);
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Sidebar Layout
|
|
||||||
Боковая навигация
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
top: var(--header-height);
|
|
||||||
width: var(--sidebar-width);
|
|
||||||
height: calc(100vh - var(--header-height));
|
|
||||||
background: rgba(10, 14, 26, 0.95);
|
|
||||||
backdrop-filter: var(--backdrop-blur);
|
|
||||||
border-right: 1px solid var(--glass-border);
|
|
||||||
z-index: 100;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
padding: 0 var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,27 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
vServer Admin Panel - Main CSS
|
|
||||||
Профессиональная модульная архитектура
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* 1. Variables & Base */
|
|
||||||
@import 'variables.css';
|
|
||||||
@import 'base.css';
|
|
||||||
|
|
||||||
/* 2. Components */
|
|
||||||
@import 'components/buttons.css';
|
|
||||||
@import 'components/badges.css';
|
|
||||||
@import 'components/cards.css';
|
|
||||||
@import 'components/forms.css';
|
|
||||||
@import 'components/tables.css';
|
|
||||||
@import 'components/modals.css';
|
|
||||||
|
|
||||||
/* 3. Layout */
|
|
||||||
@import 'layout/header.css';
|
|
||||||
@import 'layout/sidebar.css';
|
|
||||||
@import 'layout/container.css';
|
|
||||||
|
|
||||||
/* 4. Pages */
|
|
||||||
@import 'pages/dashboard.css';
|
|
||||||
@import 'pages/vaccess.css';
|
|
||||||
@import 'pages/site-creator.css';
|
|
||||||
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Dashboard Page
|
|
||||||
Главная страница с сервисами и таблицами
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Services Grid */
|
|
||||||
.services-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, 1fr);
|
|
||||||
gap: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Settings Header */
|
|
||||||
.settings-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
margin-bottom: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Settings Grid */
|
|
||||||
.settings-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: var(--space-lg);
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Settings Actions */
|
|
||||||
.settings-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Grid */
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.services-grid {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.services-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.services-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Site Creator Page
|
|
||||||
Страница создания нового сайта
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Form Section */
|
|
||||||
.form-section {
|
|
||||||
padding: var(--space-xl);
|
|
||||||
background: rgba(139, 92, 246, 0.02);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-section:hover {
|
|
||||||
background: rgba(139, 92, 246, 0.04);
|
|
||||||
border-color: rgba(139, 92, 246, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subsection Title (внутри формы) */
|
|
||||||
.form-subsection-title {
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0 0 var(--space-lg) 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-md);
|
|
||||||
padding-bottom: var(--space-sm);
|
|
||||||
border-bottom: 1px solid rgba(139, 92, 246, 0.1);
|
|
||||||
|
|
||||||
i {
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Второй и последующие подзаголовки - добавляем отступ сверху */
|
|
||||||
.form-subsection-title:not(:first-of-type) {
|
|
||||||
margin-top: var(--space-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Select Styling */
|
|
||||||
.form-input[type="text"],
|
|
||||||
.form-input[type="number"] {
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Кастомный Select */
|
|
||||||
.custom-select {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-select-trigger {
|
|
||||||
padding: 10px 40px 10px 14px;
|
|
||||||
background: var(--glass-bg-dark);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-select-trigger:hover {
|
|
||||||
border-color: rgba(139, 92, 246, 0.4);
|
|
||||||
background-color: rgba(139, 92, 246, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-select.open .custom-select-trigger {
|
|
||||||
border-color: rgba(139, 92, 246, 0.6);
|
|
||||||
box-shadow: 0 0 16px rgba(139, 92, 246, 0.2);
|
|
||||||
background-color: rgba(139, 92, 246, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-select-value {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-select-arrow {
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
font-size: 12px;
|
|
||||||
transition: transform var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-select.open .custom-select-arrow {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dropdown */
|
|
||||||
.custom-select-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 4px);
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: #1a1d2e;
|
|
||||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
||||||
max-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px);
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-select.open .custom-select-dropdown {
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar для dropdown */
|
|
||||||
.custom-select-dropdown::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-select-dropdown::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-select-dropdown::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(139, 92, 246, 0.3);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-select-dropdown::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(139, 92, 246, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Option */
|
|
||||||
.custom-select-option {
|
|
||||||
padding: 10px 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-select-option:hover {
|
|
||||||
background: rgba(139, 92, 246, 0.1);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-select-option.selected {
|
|
||||||
background: rgba(139, 92, 246, 0.2);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-select-option.selected::before {
|
|
||||||
content: '✓ ';
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* File Upload Styling */
|
|
||||||
.file-upload-wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-input {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-upload-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-md);
|
|
||||||
padding: 12px 20px;
|
|
||||||
background: var(--glass-bg-dark);
|
|
||||||
border: 2px dashed var(--glass-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--font-medium);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
i {
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(139, 92, 246, 0.05);
|
|
||||||
border-color: rgba(139, 92, 246, 0.4);
|
|
||||||
color: var(--text-primary);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-input:focus + .file-upload-btn {
|
|
||||||
border-color: rgba(139, 92, 246, 0.6);
|
|
||||||
box-shadow: 0 0 16px rgba(139, 92, 246, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Drag over state */
|
|
||||||
.file-upload-wrapper.drag-over .file-upload-btn {
|
|
||||||
background: rgba(139, 92, 246, 0.15);
|
|
||||||
border-color: rgba(139, 92, 246, 0.7);
|
|
||||||
border-style: solid;
|
|
||||||
color: var(--text-primary);
|
|
||||||
transform: scale(1.02);
|
|
||||||
box-shadow: 0 0 20px rgba(139, 92, 246, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-upload-wrapper.drag-over .file-upload-btn i {
|
|
||||||
animation: bounce 0.6s ease infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bounce {
|
|
||||||
0%, 100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* File uploaded state */
|
|
||||||
.file-uploaded {
|
|
||||||
border-style: solid;
|
|
||||||
border-color: rgba(16, 185, 129, 0.5);
|
|
||||||
background: rgba(16, 185, 129, 0.05);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: rgba(16, 185, 129, 0.6);
|
|
||||||
background: rgba(16, 185, 129, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
color: var(--accent-green);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* File status */
|
|
||||||
#certFileStatus,
|
|
||||||
#keyFileStatus,
|
|
||||||
#caFileStatus {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--font-medium);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
vAccess Editor Page
|
|
||||||
Страница редактора правил доступа
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.vaccess-page {
|
|
||||||
animation: fadeIn var(--transition-slow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Breadcrumbs */
|
|
||||||
.breadcrumbs {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-lg);
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
padding: var(--space-md) 20px;
|
|
||||||
background: rgba(139, 92, 246, 0.05);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumbs-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumbs-tabs {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-separator {
|
|
||||||
color: var(--text-muted);
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* vAccess Header */
|
|
||||||
.vaccess-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
padding: var(--space-lg);
|
|
||||||
background: rgba(139, 92, 246, 0.03);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vaccess-title-block {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vaccess-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
min-width: 200px;
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 10px var(--space-md);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.vaccess-title {
|
|
||||||
font-size: var(--text-3xl);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0 0 var(--space-sm) 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-lg);
|
|
||||||
|
|
||||||
i {
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.vaccess-subtitle {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* vAccess Tab Content */
|
|
||||||
.vaccess-tab-content {
|
|
||||||
animation: fadeIn var(--transition-slow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* vAccess Rules Container */
|
|
||||||
.vaccess-rules-container {
|
|
||||||
/* Контейнер без padding чтобы таблица была на всю ширину */
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* vAccess Rules List */
|
|
||||||
.vaccess-rules-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
max-height: 55vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty State */
|
|
||||||
.vaccess-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 80px 40px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
font-size: 64px;
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vaccess-empty h3 {
|
|
||||||
font-size: var(--text-2xl);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vaccess-empty p {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* vAccess Help */
|
|
||||||
.vaccess-help {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Help Parameters */
|
|
||||||
.help-params {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-param {
|
|
||||||
padding: 20px;
|
|
||||||
background: rgba(139, 92, 246, 0.03);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border-left: 3px solid var(--accent-purple);
|
|
||||||
|
|
||||||
strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 15px;
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: var(--space-sm) 0 0 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: 12px 0 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
padding: 3px 8px;
|
|
||||||
background: rgba(139, 92, 246, 0.15);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-warning {
|
|
||||||
color: rgba(251, 191, 36, 0.9) !important;
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Help Patterns */
|
|
||||||
.help-patterns {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pattern-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: rgba(139, 92, 246, 0.05);
|
|
||||||
border-radius: 10px;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(139, 92, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Help Examples */
|
|
||||||
.help-example {
|
|
||||||
margin-bottom: var(--space-xl);
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.example-rule {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
|
|
||||||
div {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
strong {
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-right: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Cert Manager
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.cert-manager-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-card {
|
|
||||||
background: rgba(139, 92, 246, 0.03);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: var(--space-lg);
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
padding-bottom: var(--space-md);
|
|
||||||
border-bottom: 1px solid var(--glass-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-card-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-md);
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: 24px;
|
|
||||||
color: var(--accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--text-xl);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-card-title.expired i {
|
|
||||||
color: var(--accent-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-card-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-info-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-info-item {
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
|
|
||||||
.cert-info-label {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-info-value {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: var(--font-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-info-value.valid {
|
|
||||||
color: var(--accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-info-value.expired {
|
|
||||||
color: var(--accent-red);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-domains-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
margin-top: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-domain-tag {
|
|
||||||
padding: 4px 12px;
|
|
||||||
background: rgba(139, 92, 246, 0.15);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 60px 40px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: 48px;
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: var(--text-xl);
|
|
||||||
font-weight: var(--font-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Wildcard Info Block */
|
|
||||||
.cert-wildcard-info {
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: rgba(139, 92, 246, 0.05);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-wildcard-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
margin-bottom: var(--space-xs);
|
|
||||||
font-size: var(--text-md);
|
|
||||||
font-weight: var(--font-medium);
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
|
|
||||||
i {
|
|
||||||
color: var(--accent-purple-light);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-wildcard-info > p {
|
|
||||||
margin: 0 0 var(--space-sm) 0;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-wildcard-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-wildcard-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-md);
|
|
||||||
padding: var(--space-xs) var(--space-sm);
|
|
||||||
background: rgba(139, 92, 246, 0.08);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-wildcard-item.expired {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-wildcard-domain {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-wildcard-status {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-wildcard-item.expired .cert-wildcard-status {
|
|
||||||
color: var(--accent-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-wildcard-issuer {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card Variants */
|
|
||||||
.cert-card-wildcard {
|
|
||||||
/* без особых стилей */
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-card-local {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cert-card-empty {
|
|
||||||
border-style: dashed;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
CSS Custom Properties (Design Tokens)
|
|
||||||
Профессиональная система переменных
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* Colors - Background */
|
|
||||||
--bg-primary: #0b101f;
|
|
||||||
--bg-secondary: #121420;
|
|
||||||
--bg-tertiary: #0d0f1c;
|
|
||||||
|
|
||||||
/* Colors - Glass Effect */
|
|
||||||
--glass-bg: rgba(20, 20, 40, 0.4);
|
|
||||||
--glass-bg-light: rgba(20, 20, 40, 0.3);
|
|
||||||
--glass-bg-dark: rgba(10, 14, 26, 0.5);
|
|
||||||
--glass-border: rgba(139, 92, 246, 0.15);
|
|
||||||
--glass-border-hover: rgba(139, 92, 246, 0.3);
|
|
||||||
|
|
||||||
/* Colors - Accent */
|
|
||||||
--accent-blue: #5b21b6;
|
|
||||||
--accent-blue-light: #7c3aed;
|
|
||||||
--accent-purple: #8b5cf6;
|
|
||||||
--accent-purple-light: #a78bfa;
|
|
||||||
--accent-cyan: #06b6d4;
|
|
||||||
--accent-green: #10b981;
|
|
||||||
--accent-red: #ef4444;
|
|
||||||
--accent-yellow: #f59e0b;
|
|
||||||
|
|
||||||
/* Colors - Text */
|
|
||||||
--text-primary: #e2e8f0;
|
|
||||||
--text-secondary: #94a3b8;
|
|
||||||
--text-muted: #64748b;
|
|
||||||
|
|
||||||
/* Shadows */
|
|
||||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
--shadow-md: 0 8px 32px rgba(0, 0, 0, 0.5);
|
|
||||||
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.7);
|
|
||||||
--shadow-purple: 0 8px 32px rgba(139, 92, 246, 0.3);
|
|
||||||
--shadow-green: 0 4px 12px rgba(16, 185, 129, 0.3);
|
|
||||||
--shadow-red: 0 4px 12px rgba(239, 68, 68, 0.3);
|
|
||||||
|
|
||||||
/* Spacing */
|
|
||||||
--space-xs: 4px;
|
|
||||||
--space-sm: 8px;
|
|
||||||
--space-md: 16px;
|
|
||||||
--space-lg: 24px;
|
|
||||||
--space-xl: 32px;
|
|
||||||
--space-2xl: 48px;
|
|
||||||
--space-3xl: 60px;
|
|
||||||
|
|
||||||
/* Border Radius */
|
|
||||||
--radius-sm: 6px;
|
|
||||||
--radius-md: 8px;
|
|
||||||
--radius-lg: 12px;
|
|
||||||
--radius-xl: 16px;
|
|
||||||
--radius-2xl: 10px;
|
|
||||||
--radius-full: 50%;
|
|
||||||
|
|
||||||
/* Transitions */
|
|
||||||
--transition-fast: 0.15s ease;
|
|
||||||
--transition-base: 0.2s ease;
|
|
||||||
--transition-slow: 0.3s ease;
|
|
||||||
--transition-bounce: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
--font-mono: 'Consolas', 'Courier New', monospace;
|
|
||||||
|
|
||||||
/* Font Sizes */
|
|
||||||
--text-xs: 10px;
|
|
||||||
--text-sm: 11px;
|
|
||||||
--text-base: 13px;
|
|
||||||
--text-md: 14px;
|
|
||||||
--text-lg: 16px;
|
|
||||||
--text-xl: 18px;
|
|
||||||
--text-2xl: 20px;
|
|
||||||
--text-3xl: 28px;
|
|
||||||
|
|
||||||
/* Font Weights */
|
|
||||||
--font-normal: 400;
|
|
||||||
--font-medium: 500;
|
|
||||||
--font-semibold: 600;
|
|
||||||
--font-bold: 700;
|
|
||||||
|
|
||||||
/* Z-Index Scale */
|
|
||||||
--z-base: 1;
|
|
||||||
--z-dropdown: 100;
|
|
||||||
--z-modal: 9998;
|
|
||||||
--z-notification: 9999;
|
|
||||||
--z-loader: 10000;
|
|
||||||
|
|
||||||
/* Layout */
|
|
||||||
--header-height: 60px;
|
|
||||||
--sidebar-width: 80px;
|
|
||||||
|
|
||||||
/* Backdrop Filter */
|
|
||||||
--backdrop-blur: blur(20px) saturate(180%);
|
|
||||||
--backdrop-blur-light: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Config API
|
|
||||||
Работа с конфигурацией
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
import { isWailsAvailable } from '../utils/helpers.js';
|
|
||||||
|
|
||||||
// Класс для работы с конфигурацией
|
|
||||||
class ConfigAPI {
|
|
||||||
constructor() {
|
|
||||||
this.available = isWailsAvailable();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получить конфигурацию
|
|
||||||
async getConfig() {
|
|
||||||
if (!this.available) return null;
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.GetConfig();
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сохранить конфигурацию
|
|
||||||
async saveConfig(configJSON) {
|
|
||||||
if (!this.available) return 'Error: API недоступен';
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.SaveConfig(configJSON);
|
|
||||||
} catch (error) {
|
|
||||||
return `Error: ${error.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Включить Proxy Service
|
|
||||||
async enableProxyService() {
|
|
||||||
if (!this.available) return;
|
|
||||||
try {
|
|
||||||
await window.go.admin.App.EnableProxyService();
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отключить Proxy Service
|
|
||||||
async disableProxyService() {
|
|
||||||
if (!this.available) return;
|
|
||||||
try {
|
|
||||||
await window.go.admin.App.DisableProxyService();
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Включить ACME Service
|
|
||||||
async enableACMEService() {
|
|
||||||
if (!this.available) return;
|
|
||||||
try {
|
|
||||||
await window.go.admin.App.EnableACMEService();
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отключить ACME Service
|
|
||||||
async disableACMEService() {
|
|
||||||
if (!this.available) return;
|
|
||||||
try {
|
|
||||||
await window.go.admin.App.DisableACMEService();
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Перезапустить все сервисы
|
|
||||||
async restartAllServices() {
|
|
||||||
if (!this.available) return;
|
|
||||||
try {
|
|
||||||
await window.go.admin.App.RestartAllServices();
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запустить HTTP Service
|
|
||||||
async startHTTPService() {
|
|
||||||
if (!this.available) return;
|
|
||||||
try {
|
|
||||||
await window.go.admin.App.StartHTTPService();
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Остановить HTTP Service
|
|
||||||
async stopHTTPService() {
|
|
||||||
if (!this.available) return;
|
|
||||||
try {
|
|
||||||
await window.go.admin.App.StopHTTPService();
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запустить HTTPS Service
|
|
||||||
async startHTTPSService() {
|
|
||||||
if (!this.available) return;
|
|
||||||
try {
|
|
||||||
await window.go.admin.App.StartHTTPSService();
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Остановить HTTPS Service
|
|
||||||
async stopHTTPSService() {
|
|
||||||
if (!this.available) return;
|
|
||||||
try {
|
|
||||||
await window.go.admin.App.StopHTTPSService();
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Экспортируем единственный экземпляр
|
|
||||||
export const configAPI = new ConfigAPI();
|
|
||||||
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Wails API Wrapper
|
|
||||||
Обёртка над Wails API
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
import { isWailsAvailable } from '../utils/helpers.js';
|
|
||||||
|
|
||||||
// Базовый класс для работы с Wails API
|
|
||||||
class WailsAPI {
|
|
||||||
constructor() {
|
|
||||||
this.available = isWailsAvailable();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка доступности API
|
|
||||||
checkAvailability() {
|
|
||||||
if (!this.available) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получить статус всех сервисов
|
|
||||||
async getAllServicesStatus() {
|
|
||||||
if (!this.checkAvailability()) return null;
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.GetAllServicesStatus();
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получить список сайтов
|
|
||||||
async getSitesList() {
|
|
||||||
if (!this.checkAvailability()) return [];
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.GetSitesList();
|
|
||||||
} catch (error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получить список прокси
|
|
||||||
async getProxyList() {
|
|
||||||
if (!this.checkAvailability()) return [];
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.GetProxyList();
|
|
||||||
} catch (error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получить правила vAccess
|
|
||||||
async getVAccessRules(host, isProxy) {
|
|
||||||
if (!this.checkAvailability()) return { rules: [] };
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.GetVAccessRules(host, isProxy);
|
|
||||||
} catch (error) {
|
|
||||||
return { rules: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сохранить правила vAccess
|
|
||||||
async saveVAccessRules(host, isProxy, configJSON) {
|
|
||||||
if (!this.checkAvailability()) return 'Error: API недоступен';
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.SaveVAccessRules(host, isProxy, configJSON);
|
|
||||||
} catch (error) {
|
|
||||||
return `Error: ${error.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запустить сервер
|
|
||||||
async startServer() {
|
|
||||||
if (!this.checkAvailability()) return;
|
|
||||||
try {
|
|
||||||
await window.go.admin.App.StartServer();
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Остановить сервер
|
|
||||||
async stopServer() {
|
|
||||||
if (!this.checkAvailability()) return;
|
|
||||||
try {
|
|
||||||
await window.go.admin.App.StopServer();
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверить готовность сервисов
|
|
||||||
async checkServicesReady() {
|
|
||||||
if (!this.checkAvailability()) return false;
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.CheckServicesReady();
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Открыть папку сайта
|
|
||||||
async openSiteFolder(host) {
|
|
||||||
if (!this.checkAvailability()) return;
|
|
||||||
try {
|
|
||||||
await window.go.admin.App.OpenSiteFolder(host);
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создать новый сайт
|
|
||||||
async createNewSite(siteJSON) {
|
|
||||||
if (!this.checkAvailability()) return 'Error: API недоступен';
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.CreateNewSite(siteJSON);
|
|
||||||
} catch (error) {
|
|
||||||
return `Error: ${error.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузить сертификат для сайта
|
|
||||||
async uploadCertificate(host, certType, certDataBase64) {
|
|
||||||
if (!this.checkAvailability()) return 'Error: API недоступен';
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.UploadCertificate(host, certType, certDataBase64);
|
|
||||||
} catch (error) {
|
|
||||||
return `Error: ${error.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Перезагрузить SSL сертификаты
|
|
||||||
async reloadSSLCertificates() {
|
|
||||||
if (!this.checkAvailability()) return 'Error: API недоступен';
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.ReloadSSLCertificates();
|
|
||||||
} catch (error) {
|
|
||||||
return `Error: ${error.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удалить сайт
|
|
||||||
async deleteSite(host) {
|
|
||||||
if (!this.checkAvailability()) return 'Error: API недоступен';
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.DeleteSite(host);
|
|
||||||
} catch (error) {
|
|
||||||
return `Error: ${error.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получить информацию о сертификате для домена
|
|
||||||
async getCertInfo(domain) {
|
|
||||||
if (!this.checkAvailability()) return { has_cert: false };
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.GetCertInfo(domain);
|
|
||||||
} catch (error) {
|
|
||||||
return { has_cert: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получить информацию о всех сертификатах
|
|
||||||
async getAllCertsInfo() {
|
|
||||||
if (!this.checkAvailability()) return [];
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.GetAllCertsInfo();
|
|
||||||
} catch (error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удалить сертификат
|
|
||||||
async deleteCertificate(domain) {
|
|
||||||
if (!this.checkAvailability()) return 'Error: API недоступен';
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.DeleteCertificate(domain);
|
|
||||||
} catch (error) {
|
|
||||||
return `Error: ${error.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получить SSL сертификат через Let's Encrypt
|
|
||||||
async obtainSSLCertificate(domain) {
|
|
||||||
if (!this.checkAvailability()) return 'Error: API недоступен';
|
|
||||||
try {
|
|
||||||
return await window.go.admin.App.ObtainSSLCertificate(domain);
|
|
||||||
} catch (error) {
|
|
||||||
return `Error: ${error.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Экспортируем единственный экземпляр
|
|
||||||
export const api = new WailsAPI();
|
|
||||||
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Proxy Creator Component
|
|
||||||
Управление созданием новых прокси сервисов
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
import { api } from '../api/wails.js';
|
|
||||||
import { configAPI } from '../api/config.js';
|
|
||||||
import { $, hide, show } from '../utils/dom.js';
|
|
||||||
import { notification } from '../ui/notification.js';
|
|
||||||
import { isWailsAvailable } from '../utils/helpers.js';
|
|
||||||
import { initCustomSelects } from '../ui/custom-select.js';
|
|
||||||
|
|
||||||
export class ProxyCreator {
|
|
||||||
constructor() {
|
|
||||||
this.certificates = {
|
|
||||||
certificate: null,
|
|
||||||
privatekey: null,
|
|
||||||
cabundle: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
open() {
|
|
||||||
this.hideAllSections();
|
|
||||||
show($('sectionAddProxy'));
|
|
||||||
this.resetForm();
|
|
||||||
this.attachEventListeners();
|
|
||||||
setTimeout(() => initCustomSelects(), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
hideAllSections() {
|
|
||||||
hide($('sectionServices'));
|
|
||||||
hide($('sectionSites'));
|
|
||||||
hide($('sectionProxy'));
|
|
||||||
hide($('sectionSettings'));
|
|
||||||
hide($('sectionVAccessEditor'));
|
|
||||||
hide($('sectionAddSite'));
|
|
||||||
hide($('sectionAddProxy'));
|
|
||||||
}
|
|
||||||
|
|
||||||
backToMain() {
|
|
||||||
this.hideAllSections();
|
|
||||||
show($('sectionServices'));
|
|
||||||
show($('sectionSites'));
|
|
||||||
show($('sectionProxy'));
|
|
||||||
}
|
|
||||||
|
|
||||||
resetForm() {
|
|
||||||
$('newProxyDomain').value = '';
|
|
||||||
$('newProxyLocalAddr').value = '127.0.0.1';
|
|
||||||
$('newProxyLocalPort').value = '';
|
|
||||||
$('newProxyStatus').value = 'enable';
|
|
||||||
$('newProxyServiceHTTPS').checked = false;
|
|
||||||
$('newProxyAutoHTTPS').checked = true;
|
|
||||||
$('proxyCertMode').value = 'none';
|
|
||||||
|
|
||||||
this.certificates = {
|
|
||||||
certificate: null,
|
|
||||||
privatekey: null,
|
|
||||||
cabundle: null
|
|
||||||
};
|
|
||||||
|
|
||||||
hide($('proxyCertUploadBlock'));
|
|
||||||
|
|
||||||
$('proxyCertFileStatus').innerHTML = '';
|
|
||||||
$('proxyKeyFileStatus').innerHTML = '';
|
|
||||||
$('proxyCaFileStatus').innerHTML = '';
|
|
||||||
|
|
||||||
if ($('proxyCertFileName')) $('proxyCertFileName').textContent = 'Выберите файл...';
|
|
||||||
if ($('proxyKeyFileName')) $('proxyKeyFileName').textContent = 'Выберите файл...';
|
|
||||||
if ($('proxyCaFileName')) $('proxyCaFileName').textContent = 'Выберите файл...';
|
|
||||||
|
|
||||||
if ($('proxyCertFile')) $('proxyCertFile').value = '';
|
|
||||||
if ($('proxyKeyFile')) $('proxyKeyFile').value = '';
|
|
||||||
if ($('proxyCaFile')) $('proxyCaFile').value = '';
|
|
||||||
|
|
||||||
const labels = document.querySelectorAll('#sectionAddProxy .file-upload-btn');
|
|
||||||
labels.forEach(label => label.classList.remove('file-uploaded'));
|
|
||||||
}
|
|
||||||
|
|
||||||
attachEventListeners() {
|
|
||||||
const createBtn = $('createProxyBtn');
|
|
||||||
if (createBtn) {
|
|
||||||
createBtn.onclick = async () => await this.createProxy();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setupDragAndDrop();
|
|
||||||
}
|
|
||||||
|
|
||||||
setupDragAndDrop() {
|
|
||||||
const fileWrappers = [
|
|
||||||
{ wrapper: document.querySelector('label[for="proxyCertFile"]')?.parentElement, input: $('proxyCertFile'), type: 'certificate' },
|
|
||||||
{ wrapper: document.querySelector('label[for="proxyKeyFile"]')?.parentElement, input: $('proxyKeyFile'), type: 'privatekey' },
|
|
||||||
{ wrapper: document.querySelector('label[for="proxyCaFile"]')?.parentElement, input: $('proxyCaFile'), type: 'cabundle' }
|
|
||||||
];
|
|
||||||
|
|
||||||
fileWrappers.forEach(({ wrapper, input, type }) => {
|
|
||||||
if (!wrapper || !input) return;
|
|
||||||
|
|
||||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
||||||
wrapper.addEventListener(eventName, (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragenter', 'dragover'].forEach(eventName => {
|
|
||||||
wrapper.addEventListener(eventName, () => {
|
|
||||||
wrapper.classList.add('drag-over');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragleave', 'drop'].forEach(eventName => {
|
|
||||||
wrapper.addEventListener(eventName, () => {
|
|
||||||
wrapper.classList.remove('drag-over');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.addEventListener('drop', (e) => {
|
|
||||||
const files = e.dataTransfer.files;
|
|
||||||
if (files.length > 0) {
|
|
||||||
const dataTransfer = new DataTransfer();
|
|
||||||
dataTransfer.items.add(files[0]);
|
|
||||||
input.files = dataTransfer.files;
|
|
||||||
|
|
||||||
const event = new Event('change', { bubbles: true });
|
|
||||||
input.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleCertUpload() {
|
|
||||||
const mode = $('proxyCertMode')?.value;
|
|
||||||
const block = $('proxyCertUploadBlock');
|
|
||||||
|
|
||||||
if (mode === 'upload') {
|
|
||||||
show(block);
|
|
||||||
} else {
|
|
||||||
hide(block);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCertFile(input, certType) {
|
|
||||||
const file = input.files[0];
|
|
||||||
const statusId = certType === 'certificate' ? 'proxyCertFileStatus' :
|
|
||||||
certType === 'privatekey' ? 'proxyKeyFileStatus' : 'proxyCaFileStatus';
|
|
||||||
const labelId = certType === 'certificate' ? 'proxyCertFileName' :
|
|
||||||
certType === 'privatekey' ? 'proxyKeyFileName' : 'proxyCaFileName';
|
|
||||||
|
|
||||||
const statusDiv = $(statusId);
|
|
||||||
const labelSpan = $(labelId);
|
|
||||||
const labelBtn = input.nextElementSibling;
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
this.certificates[certType] = null;
|
|
||||||
statusDiv.innerHTML = '';
|
|
||||||
if (labelSpan) labelSpan.textContent = 'Выберите файл...';
|
|
||||||
if (labelBtn) labelBtn.classList.remove('file-uploaded');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > 1024 * 1024) {
|
|
||||||
statusDiv.innerHTML = '<span style="color: #e74c3c;"><i class="fas fa-times-circle"></i> Файл слишком большой (макс 1MB)</span>';
|
|
||||||
this.certificates[certType] = null;
|
|
||||||
input.value = '';
|
|
||||||
if (labelSpan) labelSpan.textContent = 'Выберите файл...';
|
|
||||||
if (labelBtn) labelBtn.classList.remove('file-uploaded');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (labelSpan) labelSpan.textContent = file.name;
|
|
||||||
if (labelBtn) labelBtn.classList.add('file-uploaded');
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
const content = e.target.result;
|
|
||||||
this.certificates[certType] = btoa(content);
|
|
||||||
statusDiv.innerHTML = `<span style="color: #2ecc71;"><i class="fas fa-check-circle"></i> Загружен успешно</span>`;
|
|
||||||
};
|
|
||||||
reader.onerror = () => {
|
|
||||||
statusDiv.innerHTML = '<span style="color: #e74c3c;"><i class="fas fa-times-circle"></i> Ошибка чтения файла</span>';
|
|
||||||
this.certificates[certType] = null;
|
|
||||||
if (labelSpan) labelSpan.textContent = 'Выберите файл...';
|
|
||||||
if (labelBtn) labelBtn.classList.remove('file-uploaded');
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
validateForm() {
|
|
||||||
const domain = $('newProxyDomain')?.value.trim();
|
|
||||||
const localAddr = $('newProxyLocalAddr')?.value.trim();
|
|
||||||
const localPort = $('newProxyLocalPort')?.value.trim();
|
|
||||||
const certMode = $('proxyCertMode')?.value;
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
notification.error('❌ Укажите внешний домен');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!localAddr) {
|
|
||||||
notification.error('❌ Укажите локальный адрес');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!localPort) {
|
|
||||||
notification.error('❌ Укажите локальный порт');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (certMode === 'upload') {
|
|
||||||
if (!this.certificates.certificate) {
|
|
||||||
notification.error('❌ Загрузите файл certificate.crt');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!this.certificates.privatekey) {
|
|
||||||
notification.error('❌ Загрузите файл private.key');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createProxy() {
|
|
||||||
if (!this.validateForm()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isWailsAvailable()) {
|
|
||||||
notification.error('Wails API недоступен');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createBtn = $('createProxyBtn');
|
|
||||||
const originalText = createBtn.querySelector('span').textContent;
|
|
||||||
|
|
||||||
try {
|
|
||||||
createBtn.disabled = true;
|
|
||||||
createBtn.querySelector('span').textContent = 'Создание...';
|
|
||||||
|
|
||||||
const certMode = $('proxyCertMode').value;
|
|
||||||
|
|
||||||
const proxyData = {
|
|
||||||
Enable: $('newProxyStatus').value === 'enable',
|
|
||||||
ExternalDomain: $('newProxyDomain').value.trim(),
|
|
||||||
LocalAddress: $('newProxyLocalAddr').value.trim(),
|
|
||||||
LocalPort: $('newProxyLocalPort').value.trim(),
|
|
||||||
ServiceHTTPSuse: $('newProxyServiceHTTPS').checked,
|
|
||||||
AutoHTTPS: $('newProxyAutoHTTPS').checked,
|
|
||||||
AutoCreateSSL: certMode === 'auto'
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = await configAPI.getConfig();
|
|
||||||
|
|
||||||
if (!config.Proxy_Service) {
|
|
||||||
config.Proxy_Service = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
config.Proxy_Service.push(proxyData);
|
|
||||||
|
|
||||||
const result = await configAPI.saveConfig(JSON.stringify(config, null, 4));
|
|
||||||
|
|
||||||
if (result.startsWith('Error')) {
|
|
||||||
notification.error(result, 3000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
notification.success('✅ Прокси сервис создан!', 1500);
|
|
||||||
|
|
||||||
if (certMode === 'upload') {
|
|
||||||
createBtn.querySelector('span').textContent = 'Загрузка сертификатов...';
|
|
||||||
|
|
||||||
if (this.certificates.certificate) {
|
|
||||||
await api.uploadCertificate(proxyData.ExternalDomain, 'certificate', this.certificates.certificate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.certificates.privatekey) {
|
|
||||||
await api.uploadCertificate(proxyData.ExternalDomain, 'privatekey', this.certificates.privatekey);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.certificates.cabundle) {
|
|
||||||
await api.uploadCertificate(proxyData.ExternalDomain, 'cabundle', this.certificates.cabundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
notification.success('🔒 Сертификаты загружены!', 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
createBtn.querySelector('span').textContent = 'Перезапуск серверов...';
|
|
||||||
await configAPI.stopHTTPService();
|
|
||||||
await configAPI.stopHTTPSService();
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
await configAPI.startHTTPService();
|
|
||||||
await configAPI.startHTTPSService();
|
|
||||||
|
|
||||||
notification.success('🚀 Серверы перезапущены! Прокси готов к работе!', 2000);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.backToMain();
|
|
||||||
if (window.proxyManager) {
|
|
||||||
window.proxyManager.load();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
notification.error('Ошибка: ' + error.message, 3000);
|
|
||||||
} finally {
|
|
||||||
createBtn.disabled = false;
|
|
||||||
createBtn.querySelector('span').textContent = originalText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Proxy Component
|
|
||||||
Управление прокси
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
import { api } from '../api/wails.js';
|
|
||||||
import { isWailsAvailable } from '../utils/helpers.js';
|
|
||||||
import { $ } from '../utils/dom.js';
|
|
||||||
|
|
||||||
// Класс для управления прокси
|
|
||||||
export class ProxyManager {
|
|
||||||
constructor() {
|
|
||||||
this.proxiesData = [];
|
|
||||||
this.certsCache = {};
|
|
||||||
this.mockData = [
|
|
||||||
{
|
|
||||||
enable: true,
|
|
||||||
external_domain: 'git.example.ru',
|
|
||||||
local_address: '127.0.0.1',
|
|
||||||
local_port: '3333',
|
|
||||||
service_https_use: false,
|
|
||||||
auto_https: true,
|
|
||||||
auto_create_ssl: true,
|
|
||||||
status: 'active'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enable: true,
|
|
||||||
external_domain: 'api.example.com',
|
|
||||||
local_address: '127.0.0.1',
|
|
||||||
local_port: '8080',
|
|
||||||
service_https_use: true,
|
|
||||||
auto_https: false,
|
|
||||||
auto_create_ssl: false,
|
|
||||||
status: 'active'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enable: false,
|
|
||||||
external_domain: 'test.example.net',
|
|
||||||
local_address: '127.0.0.1',
|
|
||||||
local_port: '5000',
|
|
||||||
service_https_use: false,
|
|
||||||
auto_https: false,
|
|
||||||
auto_create_ssl: false,
|
|
||||||
status: 'disabled'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
this.mockCerts = {
|
|
||||||
'git.example.ru': { has_cert: true, is_expired: false, days_left: 60 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузить список прокси
|
|
||||||
async load() {
|
|
||||||
if (isWailsAvailable()) {
|
|
||||||
this.proxiesData = await api.getProxyList();
|
|
||||||
await this.loadCertsInfo();
|
|
||||||
} else {
|
|
||||||
this.proxiesData = this.mockData;
|
|
||||||
this.certsCache = this.mockCerts;
|
|
||||||
}
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузить информацию о сертификатах
|
|
||||||
async loadCertsInfo() {
|
|
||||||
const allCerts = await api.getAllCertsInfo();
|
|
||||||
this.certsCache = {};
|
|
||||||
for (const cert of allCerts) {
|
|
||||||
this.certsCache[cert.domain] = cert;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверить соответствие домена wildcard паттерну
|
|
||||||
matchesWildcard(domain, pattern) {
|
|
||||||
if (pattern.startsWith('*.')) {
|
|
||||||
const wildcardBase = pattern.slice(2);
|
|
||||||
const domainParts = domain.split('.');
|
|
||||||
if (domainParts.length >= 2) {
|
|
||||||
const domainBase = domainParts.slice(1).join('.');
|
|
||||||
return domainBase === wildcardBase;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return domain === pattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Найти сертификат для домена (включая wildcard)
|
|
||||||
findCertForDomain(domain) {
|
|
||||||
if (this.certsCache[domain]?.has_cert) {
|
|
||||||
return this.certsCache[domain];
|
|
||||||
}
|
|
||||||
|
|
||||||
const domainParts = domain.split('.');
|
|
||||||
if (domainParts.length >= 2) {
|
|
||||||
const wildcardDomain = '*.' + domainParts.slice(1).join('.');
|
|
||||||
if (this.certsCache[wildcardDomain]?.has_cert) {
|
|
||||||
return this.certsCache[wildcardDomain];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [certDomain, cert] of Object.entries(this.certsCache)) {
|
|
||||||
if (cert.has_cert && cert.dns_names) {
|
|
||||||
for (const dnsName of cert.dns_names) {
|
|
||||||
if (this.matchesWildcard(domain, dnsName)) {
|
|
||||||
return cert;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получить иконку сертификата для домена
|
|
||||||
getCertIcon(domain) {
|
|
||||||
const cert = this.findCertForDomain(domain);
|
|
||||||
if (cert) {
|
|
||||||
if (cert.is_expired) {
|
|
||||||
return `<span class="cert-icon cert-expired" title="SSL сертификат истёк"><i class="fas fa-shield-alt"></i></span>`;
|
|
||||||
} else {
|
|
||||||
return `<span class="cert-icon cert-valid" title="SSL активен (${cert.days_left} дн.)"><i class="fas fa-shield-alt"></i></span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отрисовать список прокси
|
|
||||||
render() {
|
|
||||||
const tbody = $('proxyTable')?.querySelector('tbody');
|
|
||||||
if (!tbody) return;
|
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
|
|
||||||
this.proxiesData.forEach((proxy, index) => {
|
|
||||||
const row = document.createElement('tr');
|
|
||||||
const statusBadge = proxy.status === 'active' ? 'badge-online' : 'badge-offline';
|
|
||||||
const httpsBadge = proxy.service_https_use ? 'badge-yes">HTTPS' : 'badge-no">HTTP';
|
|
||||||
const autoHttpsBadge = proxy.auto_https ? 'badge-yes">Да' : 'badge-no">Нет';
|
|
||||||
const protocol = proxy.auto_https ? 'https' : 'http';
|
|
||||||
const certIcon = this.getCertIcon(proxy.external_domain);
|
|
||||||
|
|
||||||
row.innerHTML = `
|
|
||||||
<td>${certIcon}<code class="clickable-link" data-url="${protocol}://${proxy.external_domain}">${proxy.external_domain} <i class="fas fa-external-link-alt"></i></code></td>
|
|
||||||
<td><code>${proxy.local_address}:${proxy.local_port}</code></td>
|
|
||||||
<td><span class="badge ${httpsBadge}</span></td>
|
|
||||||
<td><span class="badge ${autoHttpsBadge}</span></td>
|
|
||||||
<td><span class="badge ${statusBadge}">${proxy.status}</span></td>
|
|
||||||
<td>
|
|
||||||
<button class="icon-btn" data-action="edit-vaccess" data-host="${proxy.external_domain}" data-is-proxy="true" title="vAccess"><i class="fas fa-user-lock"></i></button>
|
|
||||||
<button class="icon-btn" data-action="open-certs" data-host="${proxy.external_domain}" data-is-proxy="true" title="SSL сертификаты"><i class="fas fa-shield-alt"></i></button>
|
|
||||||
<button class="icon-btn" data-action="edit-proxy" data-index="${index}" title="Редактировать"><i class="fas fa-edit"></i></button>
|
|
||||||
</td>
|
|
||||||
`;
|
|
||||||
|
|
||||||
tbody.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Добавляем обработчики событий
|
|
||||||
this.attachEventListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавить обработчики событий
|
|
||||||
attachEventListeners() {
|
|
||||||
// Кликабельные ссылки
|
|
||||||
const links = document.querySelectorAll('.clickable-link[data-url]');
|
|
||||||
links.forEach(link => {
|
|
||||||
link.addEventListener('click', () => {
|
|
||||||
const url = link.getAttribute('data-url');
|
|
||||||
this.openLink(url);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Кнопки действий
|
|
||||||
const buttons = document.querySelectorAll('[data-action]');
|
|
||||||
buttons.forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const action = btn.getAttribute('data-action');
|
|
||||||
this.handleAction(action, btn);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработчик действий
|
|
||||||
handleAction(action, btn) {
|
|
||||||
const host = btn.getAttribute('data-host');
|
|
||||||
const index = parseInt(btn.getAttribute('data-index'));
|
|
||||||
const isProxy = btn.getAttribute('data-is-proxy') === 'true';
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'edit-vaccess':
|
|
||||||
if (window.editVAccess) {
|
|
||||||
window.editVAccess(host, isProxy);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'open-certs':
|
|
||||||
if (window.openCertManager) {
|
|
||||||
window.openCertManager(host, isProxy, []);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'edit-proxy':
|
|
||||||
if (window.editProxy) {
|
|
||||||
window.editProxy(index);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Открыть ссылку
|
|
||||||
openLink(url) {
|
|
||||||
if (window.runtime?.BrowserOpenURL) {
|
|
||||||
window.runtime.BrowserOpenURL(url);
|
|
||||||
} else {
|
|
||||||
window.open(url, '_blank');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Services Component
|
|
||||||
Управление сервисами
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
import { api } from '../api/wails.js';
|
|
||||||
import { $, $$, addClass, removeClass } from '../utils/dom.js';
|
|
||||||
import { notification } from '../ui/notification.js';
|
|
||||||
import { sleep, isWailsAvailable } from '../utils/helpers.js';
|
|
||||||
|
|
||||||
// Класс для управления сервисами
|
|
||||||
export class ServicesManager {
|
|
||||||
constructor() {
|
|
||||||
this.serverRunning = true;
|
|
||||||
this.isOperating = false;
|
|
||||||
this.controlBtn = $('serverControlBtn');
|
|
||||||
this.statusIndicator = document.querySelector('.status-indicator');
|
|
||||||
this.statusText = document.querySelector('.status-text');
|
|
||||||
this.btnText = document.querySelector('.btn-text');
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
if (this.controlBtn) {
|
|
||||||
this.controlBtn.addEventListener('click', () => this.toggleServer());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Подписка на события
|
|
||||||
if (window.runtime?.EventsOn) {
|
|
||||||
window.runtime.EventsOn('service:changed', (status) => {
|
|
||||||
this.renderServices(status);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.runtime.EventsOn('server:already_running', () => {
|
|
||||||
notification.error('vServer уже запущен!<br><br>Закройте другой экземпляр перед запуском нового.', 5000);
|
|
||||||
this.setServerStatus(false, 'Уже запущен в другом процессе');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Переключить состояние сервера
|
|
||||||
async toggleServer() {
|
|
||||||
if (this.serverRunning) {
|
|
||||||
await this.stopServer();
|
|
||||||
} else {
|
|
||||||
await this.startServer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запустить сервер
|
|
||||||
async startServer() {
|
|
||||||
this.isOperating = true;
|
|
||||||
this.controlBtn.disabled = true;
|
|
||||||
this.statusText.textContent = 'Запускается...';
|
|
||||||
this.btnText.textContent = 'Ожидайте...';
|
|
||||||
this.setAllServicesPending('Запуск');
|
|
||||||
|
|
||||||
await api.startServer();
|
|
||||||
|
|
||||||
// Ждём пока все запустятся
|
|
||||||
let attempts = 0;
|
|
||||||
while (attempts < 20) {
|
|
||||||
await sleep(500);
|
|
||||||
if (await api.checkServicesReady()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isOperating = false;
|
|
||||||
this.setServerStatus(true, 'Сервер запущен');
|
|
||||||
removeClass(this.controlBtn, 'start-mode');
|
|
||||||
this.btnText.textContent = 'Остановить';
|
|
||||||
this.controlBtn.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Остановить сервер
|
|
||||||
async stopServer() {
|
|
||||||
this.isOperating = true;
|
|
||||||
this.controlBtn.disabled = true;
|
|
||||||
this.statusText.textContent = 'Выключается...';
|
|
||||||
this.btnText.textContent = 'Ожидайте...';
|
|
||||||
this.setAllServicesPending('Остановка');
|
|
||||||
|
|
||||||
await api.stopServer();
|
|
||||||
await sleep(1500);
|
|
||||||
|
|
||||||
this.isOperating = false;
|
|
||||||
this.setServerStatus(false, 'Сервер остановлен');
|
|
||||||
addClass(this.controlBtn, 'start-mode');
|
|
||||||
this.btnText.textContent = 'Запустить';
|
|
||||||
this.controlBtn.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Установить статус сервера
|
|
||||||
setServerStatus(isOnline, text) {
|
|
||||||
this.serverRunning = isOnline;
|
|
||||||
|
|
||||||
if (isOnline) {
|
|
||||||
removeClass(this.statusIndicator, 'status-offline');
|
|
||||||
addClass(this.statusIndicator, 'status-online');
|
|
||||||
} else {
|
|
||||||
removeClass(this.statusIndicator, 'status-online');
|
|
||||||
addClass(this.statusIndicator, 'status-offline');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.statusText.textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Установить всем сервисам статус pending
|
|
||||||
setAllServicesPending(text) {
|
|
||||||
const badges = $$('.service-card .badge');
|
|
||||||
badges.forEach(badge => {
|
|
||||||
badge.className = 'badge badge-pending';
|
|
||||||
badge.textContent = text;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отрисовать статусы сервисов
|
|
||||||
renderServices(data) {
|
|
||||||
const services = [data.http, data.https, data.mysql, data.php, data.proxy];
|
|
||||||
const cards = $$('.service-card');
|
|
||||||
|
|
||||||
services.forEach((service, index) => {
|
|
||||||
const card = cards[index];
|
|
||||||
if (!card) return;
|
|
||||||
|
|
||||||
const badge = card.querySelector('.badge');
|
|
||||||
const infoValues = card.querySelectorAll('.info-value');
|
|
||||||
|
|
||||||
// Обновляем badge только если НЕ в процессе операции
|
|
||||||
if (badge && !this.isOperating) {
|
|
||||||
if (service.status) {
|
|
||||||
badge.className = 'badge badge-online';
|
|
||||||
badge.textContent = 'Активен';
|
|
||||||
} else {
|
|
||||||
badge.className = 'badge badge-offline';
|
|
||||||
badge.textContent = 'Остановлен';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем значения
|
|
||||||
if (service.name === 'Proxy') {
|
|
||||||
if (infoValues[0] && service.info) {
|
|
||||||
infoValues[0].textContent = service.info;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (infoValues[0]) {
|
|
||||||
infoValues[0].textContent = service.port;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузить статусы сервисов
|
|
||||||
async loadStatus() {
|
|
||||||
if (isWailsAvailable()) {
|
|
||||||
const data = await api.getAllServicesStatus();
|
|
||||||
if (data) {
|
|
||||||
this.renderServices(data);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Используем тестовые данные если Wails недоступен
|
|
||||||
const mockData = {
|
|
||||||
http: { name: 'HTTP', status: true, port: '80' },
|
|
||||||
https: { name: 'HTTPS', status: true, port: '443' },
|
|
||||||
mysql: { name: 'MySQL', status: true, port: '3306' },
|
|
||||||
php: { name: 'PHP', status: true, port: '8000-8003' },
|
|
||||||
proxy: { name: 'Proxy', status: true, port: '', info: '1 из 3' }
|
|
||||||
};
|
|
||||||
this.renderServices(mockData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Site Creator Component
|
|
||||||
Управление созданием новых сайтов
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
import { api } from '../api/wails.js';
|
|
||||||
import { configAPI } from '../api/config.js';
|
|
||||||
import { $, hide, show } from '../utils/dom.js';
|
|
||||||
import { notification } from '../ui/notification.js';
|
|
||||||
import { isWailsAvailable } from '../utils/helpers.js';
|
|
||||||
import { initCustomSelects } from '../ui/custom-select.js';
|
|
||||||
|
|
||||||
// Класс для создания новых сайтов
|
|
||||||
export class SiteCreator {
|
|
||||||
constructor() {
|
|
||||||
this.aliases = [];
|
|
||||||
this.certificates = {
|
|
||||||
certificate: null,
|
|
||||||
privatekey: null,
|
|
||||||
cabundle: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Открыть страницу создания сайта
|
|
||||||
open() {
|
|
||||||
// Скрываем все секции
|
|
||||||
this.hideAllSections();
|
|
||||||
|
|
||||||
// Показываем страницу создания
|
|
||||||
show($('sectionAddSite'));
|
|
||||||
|
|
||||||
// Очищаем форму
|
|
||||||
this.resetForm();
|
|
||||||
|
|
||||||
// Привязываем обработчики
|
|
||||||
this.attachEventListeners();
|
|
||||||
|
|
||||||
// Инициализируем кастомные select'ы
|
|
||||||
setTimeout(() => initCustomSelects(), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Скрыть все секции
|
|
||||||
hideAllSections() {
|
|
||||||
hide($('sectionServices'));
|
|
||||||
hide($('sectionSites'));
|
|
||||||
hide($('sectionProxy'));
|
|
||||||
hide($('sectionSettings'));
|
|
||||||
hide($('sectionVAccessEditor'));
|
|
||||||
hide($('sectionAddSite'));
|
|
||||||
hide($('sectionAddProxy'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вернуться на главную
|
|
||||||
backToMain() {
|
|
||||||
this.hideAllSections();
|
|
||||||
show($('sectionServices'));
|
|
||||||
show($('sectionSites'));
|
|
||||||
show($('sectionProxy'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Очистить форму
|
|
||||||
resetForm() {
|
|
||||||
$('newSiteName').value = '';
|
|
||||||
$('newSiteHost').value = '';
|
|
||||||
$('newSiteAliasInput').value = '';
|
|
||||||
$('newSiteRootFile').value = 'index.html';
|
|
||||||
$('newSiteStatus').value = 'active';
|
|
||||||
$('newSiteRouting').checked = true;
|
|
||||||
$('certMode').value = 'none';
|
|
||||||
|
|
||||||
this.aliases = [];
|
|
||||||
this.certificates = {
|
|
||||||
certificate: null,
|
|
||||||
privatekey: null,
|
|
||||||
cabundle: null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Скрываем блок загрузки сертификатов
|
|
||||||
hide($('certUploadBlock'));
|
|
||||||
|
|
||||||
// Очищаем статусы файлов
|
|
||||||
$('certFileStatus').innerHTML = '';
|
|
||||||
$('keyFileStatus').innerHTML = '';
|
|
||||||
$('caFileStatus').innerHTML = '';
|
|
||||||
|
|
||||||
// Очищаем labels файлов
|
|
||||||
if ($('certFileName')) $('certFileName').textContent = 'Выберите файл...';
|
|
||||||
if ($('keyFileName')) $('keyFileName').textContent = 'Выберите файл...';
|
|
||||||
if ($('caFileName')) $('caFileName').textContent = 'Выберите файл...';
|
|
||||||
|
|
||||||
// Очищаем input файлов
|
|
||||||
if ($('certFile')) $('certFile').value = '';
|
|
||||||
if ($('keyFile')) $('keyFile').value = '';
|
|
||||||
if ($('caFile')) $('caFile').value = '';
|
|
||||||
|
|
||||||
// Убираем класс uploaded
|
|
||||||
const labels = document.querySelectorAll('.file-upload-btn');
|
|
||||||
labels.forEach(label => label.classList.remove('file-uploaded'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Привязать обработчики событий
|
|
||||||
attachEventListeners() {
|
|
||||||
const createBtn = $('createSiteBtn');
|
|
||||||
if (createBtn) {
|
|
||||||
createBtn.onclick = async () => await this.createSite();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drag & Drop для файлов сертификатов
|
|
||||||
this.setupDragAndDrop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Настроить Drag & Drop для файлов
|
|
||||||
setupDragAndDrop() {
|
|
||||||
const fileWrappers = [
|
|
||||||
{ wrapper: document.querySelector('label[for="certFile"]')?.parentElement, input: $('certFile'), type: 'certificate' },
|
|
||||||
{ wrapper: document.querySelector('label[for="keyFile"]')?.parentElement, input: $('keyFile'), type: 'privatekey' },
|
|
||||||
{ wrapper: document.querySelector('label[for="caFile"]')?.parentElement, input: $('caFile'), type: 'cabundle' }
|
|
||||||
];
|
|
||||||
|
|
||||||
fileWrappers.forEach(({ wrapper, input, type }) => {
|
|
||||||
if (!wrapper || !input) return;
|
|
||||||
|
|
||||||
// Предотвращаем стандартное поведение
|
|
||||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
||||||
wrapper.addEventListener(eventName, (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Подсветка при наведении файла
|
|
||||||
['dragenter', 'dragover'].forEach(eventName => {
|
|
||||||
wrapper.addEventListener(eventName, () => {
|
|
||||||
wrapper.classList.add('drag-over');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
['dragleave', 'drop'].forEach(eventName => {
|
|
||||||
wrapper.addEventListener(eventName, () => {
|
|
||||||
wrapper.classList.remove('drag-over');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Обработка dropped файла
|
|
||||||
wrapper.addEventListener('drop', (e) => {
|
|
||||||
const files = e.dataTransfer.files;
|
|
||||||
if (files.length > 0) {
|
|
||||||
// Создаём объект DataTransfer и присваиваем файлы input'у
|
|
||||||
const dataTransfer = new DataTransfer();
|
|
||||||
dataTransfer.items.add(files[0]);
|
|
||||||
input.files = dataTransfer.files;
|
|
||||||
|
|
||||||
// Триггерим событие change
|
|
||||||
const event = new Event('change', { bubbles: true });
|
|
||||||
input.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Парсить aliases из строки (через запятую)
|
|
||||||
parseAliases() {
|
|
||||||
const input = $('newSiteAliasInput');
|
|
||||||
const value = input?.value.trim();
|
|
||||||
|
|
||||||
if (!value) {
|
|
||||||
this.aliases = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Разделяем по запятой и очищаем
|
|
||||||
this.aliases = value
|
|
||||||
.split(',')
|
|
||||||
.map(alias => alias.trim())
|
|
||||||
.filter(alias => alias.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Переключить видимость блока загрузки сертификатов
|
|
||||||
toggleCertUpload() {
|
|
||||||
const mode = $('certMode')?.value;
|
|
||||||
const block = $('certUploadBlock');
|
|
||||||
|
|
||||||
if (mode === 'upload') {
|
|
||||||
show(block);
|
|
||||||
} else {
|
|
||||||
hide(block);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработать выбор файла сертификата
|
|
||||||
handleCertFile(input, certType) {
|
|
||||||
const file = input.files[0];
|
|
||||||
const statusId = certType === 'certificate' ? 'certFileStatus' :
|
|
||||||
certType === 'privatekey' ? 'keyFileStatus' : 'caFileStatus';
|
|
||||||
const labelId = certType === 'certificate' ? 'certFileName' :
|
|
||||||
certType === 'privatekey' ? 'keyFileName' : 'caFileName';
|
|
||||||
|
|
||||||
const statusDiv = $(statusId);
|
|
||||||
const labelSpan = $(labelId);
|
|
||||||
const labelBtn = input.nextElementSibling; // label элемент
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
this.certificates[certType] = null;
|
|
||||||
statusDiv.innerHTML = '';
|
|
||||||
if (labelSpan) labelSpan.textContent = 'Выберите файл...';
|
|
||||||
if (labelBtn) labelBtn.classList.remove('file-uploaded');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем размер файла (макс 1MB)
|
|
||||||
if (file.size > 1024 * 1024) {
|
|
||||||
statusDiv.innerHTML = '<span style="color: #e74c3c;"><i class="fas fa-times-circle"></i> Файл слишком большой (макс 1MB)</span>';
|
|
||||||
this.certificates[certType] = null;
|
|
||||||
input.value = '';
|
|
||||||
if (labelSpan) labelSpan.textContent = 'Выберите файл...';
|
|
||||||
if (labelBtn) labelBtn.classList.remove('file-uploaded');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем UI
|
|
||||||
if (labelSpan) labelSpan.textContent = file.name;
|
|
||||||
if (labelBtn) labelBtn.classList.add('file-uploaded');
|
|
||||||
|
|
||||||
// Читаем файл
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
const content = e.target.result;
|
|
||||||
// Сохраняем как base64
|
|
||||||
this.certificates[certType] = btoa(content);
|
|
||||||
statusDiv.innerHTML = `<span style="color: #2ecc71;"><i class="fas fa-check-circle"></i> Загружен успешно</span>`;
|
|
||||||
};
|
|
||||||
reader.onerror = () => {
|
|
||||||
statusDiv.innerHTML = '<span style="color: #e74c3c;"><i class="fas fa-times-circle"></i> Ошибка чтения файла</span>';
|
|
||||||
this.certificates[certType] = null;
|
|
||||||
if (labelSpan) labelSpan.textContent = 'Выберите файл...';
|
|
||||||
if (labelBtn) labelBtn.classList.remove('file-uploaded');
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Валидация формы
|
|
||||||
validateForm() {
|
|
||||||
const name = $('newSiteName')?.value.trim();
|
|
||||||
const host = $('newSiteHost')?.value.trim();
|
|
||||||
const rootFile = $('newSiteRootFile')?.value;
|
|
||||||
const certMode = $('certMode')?.value;
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
notification.error('❌ Укажите название сайта');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!host) {
|
|
||||||
notification.error('❌ Укажите host (домен)');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rootFile) {
|
|
||||||
notification.error('❌ Укажите root файл');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка сертификатов если режим загрузки
|
|
||||||
if (certMode === 'upload') {
|
|
||||||
if (!this.certificates.certificate) {
|
|
||||||
notification.error('❌ Загрузите файл certificate.crt');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!this.certificates.privatekey) {
|
|
||||||
notification.error('❌ Загрузите файл private.key');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создать сайт
|
|
||||||
async createSite() {
|
|
||||||
if (!this.validateForm()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isWailsAvailable()) {
|
|
||||||
notification.error('Wails API недоступен');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createBtn = $('createSiteBtn');
|
|
||||||
const originalText = createBtn.querySelector('span').textContent;
|
|
||||||
|
|
||||||
try {
|
|
||||||
createBtn.disabled = true;
|
|
||||||
createBtn.querySelector('span').textContent = 'Создание...';
|
|
||||||
|
|
||||||
// Парсим aliases из поля ввода
|
|
||||||
this.parseAliases();
|
|
||||||
|
|
||||||
// Определяем режим сертификата
|
|
||||||
const certMode = $('certMode').value;
|
|
||||||
|
|
||||||
// Собираем данные сайта
|
|
||||||
const siteData = {
|
|
||||||
name: $('newSiteName').value.trim(),
|
|
||||||
host: $('newSiteHost').value.trim(),
|
|
||||||
alias: this.aliases,
|
|
||||||
status: $('newSiteStatus').value,
|
|
||||||
root_file: $('newSiteRootFile').value,
|
|
||||||
root_file_routing: $('newSiteRouting').checked,
|
|
||||||
AutoCreateSSL: certMode === 'auto'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Создаём сайт
|
|
||||||
const siteJSON = JSON.stringify(siteData);
|
|
||||||
const result = await api.createNewSite(siteJSON);
|
|
||||||
|
|
||||||
if (result.startsWith('Error')) {
|
|
||||||
notification.error(result, 3000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
notification.success('✅ Сайт успешно создан!', 1500);
|
|
||||||
|
|
||||||
// Загружаем сертификаты если нужно
|
|
||||||
if (certMode === 'upload') {
|
|
||||||
createBtn.querySelector('span').textContent = 'Загрузка сертификатов...';
|
|
||||||
|
|
||||||
// Загружаем certificate
|
|
||||||
if (this.certificates.certificate) {
|
|
||||||
await api.uploadCertificate(siteData.host, 'certificate', this.certificates.certificate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем private key
|
|
||||||
if (this.certificates.privatekey) {
|
|
||||||
await api.uploadCertificate(siteData.host, 'privatekey', this.certificates.privatekey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем ca bundle если есть
|
|
||||||
if (this.certificates.cabundle) {
|
|
||||||
await api.uploadCertificate(siteData.host, 'cabundle', this.certificates.cabundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
notification.success('🔒 Сертификаты загружены!', 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Перезапускаем HTTP/HTTPS
|
|
||||||
createBtn.querySelector('span').textContent = 'Перезапуск серверов...';
|
|
||||||
await configAPI.stopHTTPService();
|
|
||||||
await configAPI.stopHTTPSService();
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
await configAPI.startHTTPService();
|
|
||||||
await configAPI.startHTTPSService();
|
|
||||||
|
|
||||||
notification.success('🚀 Серверы перезапущены! Сайт готов к работе!', 2000);
|
|
||||||
|
|
||||||
// Возвращаемся на главную
|
|
||||||
setTimeout(() => {
|
|
||||||
this.backToMain();
|
|
||||||
// Перезагружаем список сайтов
|
|
||||||
if (window.sitesManager) {
|
|
||||||
window.sitesManager.load();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
notification.error('Ошибка: ' + error.message, 3000);
|
|
||||||
} finally {
|
|
||||||
createBtn.disabled = false;
|
|
||||||
createBtn.querySelector('span').textContent = originalText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Sites Component
|
|
||||||
Управление сайтами
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
import { api } from '../api/wails.js';
|
|
||||||
import { isWailsAvailable } from '../utils/helpers.js';
|
|
||||||
import { $ } from '../utils/dom.js';
|
|
||||||
|
|
||||||
// Класс для управления сайтами
|
|
||||||
export class SitesManager {
|
|
||||||
constructor() {
|
|
||||||
this.sitesData = [];
|
|
||||||
this.certsCache = {};
|
|
||||||
this.mockData = [
|
|
||||||
{
|
|
||||||
name: 'Home Voxsel',
|
|
||||||
host: 'home.voxsel.ru',
|
|
||||||
alias: ['home.voxsel.com'],
|
|
||||||
status: 'active',
|
|
||||||
root_file: 'index.html',
|
|
||||||
root_file_routing: true,
|
|
||||||
auto_create_ssl: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Finance',
|
|
||||||
host: 'finance.voxsel.ru',
|
|
||||||
alias: [],
|
|
||||||
status: 'active',
|
|
||||||
root_file: 'index.php',
|
|
||||||
root_file_routing: false,
|
|
||||||
auto_create_ssl: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Локальный сайт',
|
|
||||||
host: '127.0.0.1',
|
|
||||||
alias: ['localhost'],
|
|
||||||
status: 'active',
|
|
||||||
root_file: 'index.html',
|
|
||||||
root_file_routing: true,
|
|
||||||
auto_create_ssl: false
|
|
||||||
}
|
|
||||||
];
|
|
||||||
this.mockCerts = {
|
|
||||||
'voxsel.ru': { has_cert: true, is_expired: false, days_left: 79, dns_names: ['*.voxsel.com', '*.voxsel.ru', 'voxsel.com', 'voxsel.ru'] },
|
|
||||||
'finance.voxsel.ru': { has_cert: true, is_expired: false, days_left: 89, dns_names: ['finance.voxsel.ru'] }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузить список сайтов
|
|
||||||
async load() {
|
|
||||||
if (isWailsAvailable()) {
|
|
||||||
this.sitesData = await api.getSitesList();
|
|
||||||
await this.loadCertsInfo();
|
|
||||||
} else {
|
|
||||||
this.sitesData = this.mockData;
|
|
||||||
this.certsCache = this.mockCerts;
|
|
||||||
}
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузить информацию о сертификатах
|
|
||||||
async loadCertsInfo() {
|
|
||||||
const allCerts = await api.getAllCertsInfo();
|
|
||||||
this.certsCache = {};
|
|
||||||
for (const cert of allCerts) {
|
|
||||||
this.certsCache[cert.domain] = cert;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверить соответствие домена wildcard паттерну
|
|
||||||
matchesWildcard(domain, pattern) {
|
|
||||||
if (pattern.startsWith('*.')) {
|
|
||||||
const wildcardBase = pattern.slice(2);
|
|
||||||
const domainParts = domain.split('.');
|
|
||||||
if (domainParts.length >= 2) {
|
|
||||||
const domainBase = domainParts.slice(1).join('.');
|
|
||||||
return domainBase === wildcardBase;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return domain === pattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Найти сертификат для домена (включая wildcard)
|
|
||||||
findCertForDomain(domain) {
|
|
||||||
if (this.certsCache[domain]?.has_cert) {
|
|
||||||
return this.certsCache[domain];
|
|
||||||
}
|
|
||||||
|
|
||||||
const domainParts = domain.split('.');
|
|
||||||
if (domainParts.length >= 2) {
|
|
||||||
const wildcardDomain = '*.' + domainParts.slice(1).join('.');
|
|
||||||
if (this.certsCache[wildcardDomain]?.has_cert) {
|
|
||||||
return this.certsCache[wildcardDomain];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [certDomain, cert] of Object.entries(this.certsCache)) {
|
|
||||||
if (cert.has_cert && cert.dns_names) {
|
|
||||||
for (const dnsName of cert.dns_names) {
|
|
||||||
if (this.matchesWildcard(domain, dnsName)) {
|
|
||||||
return cert;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получить иконку сертификата для домена
|
|
||||||
getCertIcon(host, aliases = []) {
|
|
||||||
const allDomains = [host, ...aliases.filter(a => !a.includes('*'))];
|
|
||||||
|
|
||||||
for (const domain of allDomains) {
|
|
||||||
const cert = this.findCertForDomain(domain);
|
|
||||||
if (cert) {
|
|
||||||
if (cert.is_expired) {
|
|
||||||
return `<span class="cert-icon cert-expired" title="SSL сертификат истёк"><i class="fas fa-shield-alt"></i></span>`;
|
|
||||||
} else {
|
|
||||||
return `<span class="cert-icon cert-valid" title="SSL активен (${cert.days_left} дн.)"><i class="fas fa-shield-alt"></i></span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отрисовать список сайтов
|
|
||||||
render() {
|
|
||||||
const tbody = $('sitesTable')?.querySelector('tbody');
|
|
||||||
if (!tbody) return;
|
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
|
|
||||||
this.sitesData.forEach((site, index) => {
|
|
||||||
const row = document.createElement('tr');
|
|
||||||
const statusBadge = site.status === 'active' ? 'badge-online' : 'badge-offline';
|
|
||||||
const aliases = site.alias.join(', ');
|
|
||||||
const certIcon = this.getCertIcon(site.host, site.alias);
|
|
||||||
|
|
||||||
row.innerHTML = `
|
|
||||||
<td>${certIcon}${site.name}</td>
|
|
||||||
<td><code class="clickable-link" data-url="http://${site.host}">${site.host} <i class="fas fa-external-link-alt"></i></code></td>
|
|
||||||
<td><code>${aliases}</code></td>
|
|
||||||
<td><span class="badge ${statusBadge}">${site.status}</span></td>
|
|
||||||
<td><code>${site.root_file}</code></td>
|
|
||||||
<td>
|
|
||||||
<button class="icon-btn" data-action="open-folder" data-host="${site.host}" title="Открыть папку"><i class="fas fa-folder-open"></i></button>
|
|
||||||
<button class="icon-btn" data-action="edit-vaccess" data-host="${site.host}" data-is-proxy="false" title="vAccess"><i class="fas fa-user-lock"></i></button>
|
|
||||||
<button class="icon-btn" data-action="open-certs" data-host="${site.host}" data-aliases="${site.alias.join(',')}" data-is-proxy="false" title="SSL сертификаты"><i class="fas fa-shield-alt"></i></button>
|
|
||||||
<button class="icon-btn" data-action="edit-site" data-index="${index}" title="Редактировать"><i class="fas fa-edit"></i></button>
|
|
||||||
</td>
|
|
||||||
`;
|
|
||||||
|
|
||||||
tbody.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Добавляем обработчики событий
|
|
||||||
this.attachEventListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавить обработчики событий
|
|
||||||
attachEventListeners() {
|
|
||||||
// Кликабельные ссылки
|
|
||||||
const links = document.querySelectorAll('.clickable-link[data-url]');
|
|
||||||
links.forEach(link => {
|
|
||||||
link.addEventListener('click', () => {
|
|
||||||
const url = link.getAttribute('data-url');
|
|
||||||
this.openLink(url);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Кнопки действий
|
|
||||||
const buttons = document.querySelectorAll('[data-action]');
|
|
||||||
buttons.forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const action = btn.getAttribute('data-action');
|
|
||||||
this.handleAction(action, btn);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработчик действий
|
|
||||||
async handleAction(action, btn) {
|
|
||||||
const host = btn.getAttribute('data-host');
|
|
||||||
const index = parseInt(btn.getAttribute('data-index'));
|
|
||||||
const isProxy = btn.getAttribute('data-is-proxy') === 'true';
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'open-folder':
|
|
||||||
await api.openSiteFolder(host);
|
|
||||||
break;
|
|
||||||
case 'edit-vaccess':
|
|
||||||
if (window.editVAccess) {
|
|
||||||
window.editVAccess(host, isProxy);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'open-certs':
|
|
||||||
if (window.openCertManager) {
|
|
||||||
const aliasesStr = btn.getAttribute('data-aliases') || '';
|
|
||||||
const aliases = aliasesStr ? aliasesStr.split(',').filter(a => a) : [];
|
|
||||||
window.openCertManager(host, isProxy, aliases);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'edit-site':
|
|
||||||
if (window.editSite) {
|
|
||||||
window.editSite(index);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Открыть ссылку
|
|
||||||
openLink(url) {
|
|
||||||
if (window.runtime?.BrowserOpenURL) {
|
|
||||||
window.runtime.BrowserOpenURL(url);
|
|
||||||
} else {
|
|
||||||
window.open(url, '_blank');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
vAccess Component
|
|
||||||
Управление правилами доступа
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
import { api } from '../api/wails.js';
|
|
||||||
import { $, hide, show } from '../utils/dom.js';
|
|
||||||
import { notification } from '../ui/notification.js';
|
|
||||||
import { modal } from '../ui/modal.js';
|
|
||||||
import { isWailsAvailable } from '../utils/helpers.js';
|
|
||||||
|
|
||||||
// Класс для управления vAccess правилами
|
|
||||||
export class VAccessManager {
|
|
||||||
constructor() {
|
|
||||||
this.vAccessHost = '';
|
|
||||||
this.vAccessIsProxy = false;
|
|
||||||
this.vAccessRules = [];
|
|
||||||
this.vAccessReturnSection = 'sectionSites';
|
|
||||||
this.draggedIndex = null;
|
|
||||||
this.editingField = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Открыть редактор vAccess
|
|
||||||
async open(host, isProxy) {
|
|
||||||
this.vAccessHost = host;
|
|
||||||
this.vAccessIsProxy = isProxy;
|
|
||||||
|
|
||||||
// Запоминаем откуда пришли
|
|
||||||
if ($('sectionSites').style.display !== 'none') {
|
|
||||||
this.vAccessReturnSection = 'sectionSites';
|
|
||||||
} else if ($('sectionProxy').style.display !== 'none') {
|
|
||||||
this.vAccessReturnSection = 'sectionProxy';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем правила
|
|
||||||
if (isWailsAvailable()) {
|
|
||||||
const config = await api.getVAccessRules(host, isProxy);
|
|
||||||
this.vAccessRules = config.rules || [];
|
|
||||||
} else {
|
|
||||||
// Тестовые данные для браузерного режима
|
|
||||||
this.vAccessRules = [
|
|
||||||
{
|
|
||||||
type: 'Disable',
|
|
||||||
type_file: ['*.php'],
|
|
||||||
path_access: ['/uploads/*'],
|
|
||||||
ip_list: [],
|
|
||||||
exceptions_dir: [],
|
|
||||||
url_error: '404'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем UI
|
|
||||||
const subtitle = isProxy
|
|
||||||
? 'Управление правилами доступа для прокси-сервиса'
|
|
||||||
: 'Управление правилами доступа для сайта';
|
|
||||||
|
|
||||||
$('breadcrumbHost').textContent = host;
|
|
||||||
$('vAccessSubtitle').textContent = subtitle;
|
|
||||||
|
|
||||||
// Переключаем на страницу редактора
|
|
||||||
this.hideAllSections();
|
|
||||||
show($('sectionVAccessEditor'));
|
|
||||||
|
|
||||||
// Рендерим правила и показываем правильную вкладку
|
|
||||||
this.renderRulesList();
|
|
||||||
this.switchTab('rules');
|
|
||||||
|
|
||||||
// Привязываем кнопку сохранения
|
|
||||||
const saveBtn = $('saveVAccessBtn');
|
|
||||||
if (saveBtn) {
|
|
||||||
saveBtn.onclick = async () => await this.save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Скрыть все секции
|
|
||||||
hideAllSections() {
|
|
||||||
hide($('sectionServices'));
|
|
||||||
hide($('sectionSites'));
|
|
||||||
hide($('sectionProxy'));
|
|
||||||
hide($('sectionSettings'));
|
|
||||||
hide($('sectionVAccessEditor'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вернуться на главную
|
|
||||||
backToMain() {
|
|
||||||
this.hideAllSections();
|
|
||||||
show($('sectionServices'));
|
|
||||||
show($('sectionSites'));
|
|
||||||
show($('sectionProxy'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Переключить вкладку
|
|
||||||
switchTab(tab) {
|
|
||||||
const tabs = document.querySelectorAll('.vaccess-tab[data-tab]');
|
|
||||||
tabs.forEach(t => {
|
|
||||||
if (t.dataset.tab === tab) {
|
|
||||||
t.classList.add('active');
|
|
||||||
} else {
|
|
||||||
t.classList.remove('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tab === 'rules') {
|
|
||||||
show($('vAccessRulesTab'));
|
|
||||||
hide($('vAccessHelpTab'));
|
|
||||||
} else {
|
|
||||||
hide($('vAccessRulesTab'));
|
|
||||||
show($('vAccessHelpTab'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сохранить изменения
|
|
||||||
async save() {
|
|
||||||
if (isWailsAvailable()) {
|
|
||||||
const config = { rules: this.vAccessRules };
|
|
||||||
const configJSON = JSON.stringify(config);
|
|
||||||
const result = await api.saveVAccessRules(this.vAccessHost, this.vAccessIsProxy, configJSON);
|
|
||||||
|
|
||||||
if (result.startsWith('Error')) {
|
|
||||||
notification.error(result, 2000);
|
|
||||||
} else {
|
|
||||||
notification.success('✅ Правила vAccess успешно сохранены', 1000);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Браузерный режим - просто показываем уведомление
|
|
||||||
notification.success('Данные сохранены (тестовый режим)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отрисовать список правил
|
|
||||||
renderRulesList() {
|
|
||||||
const tbody = $('vAccessTableBody');
|
|
||||||
const emptyState = $('vAccessEmpty');
|
|
||||||
const table = document.querySelector('.vaccess-table');
|
|
||||||
|
|
||||||
if (!tbody) return;
|
|
||||||
|
|
||||||
// Показываем/скрываем пустое состояние
|
|
||||||
if (this.vAccessRules.length === 0) {
|
|
||||||
if (table) hide(table);
|
|
||||||
if (emptyState) show(emptyState);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
if (table) show(table);
|
|
||||||
if (emptyState) hide(emptyState);
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = this.vAccessRules.map((rule, index) => `
|
|
||||||
<tr draggable="true" data-index="${index}">
|
|
||||||
<td class="drag-handle"><i class="fas fa-grip-vertical"></i></td>
|
|
||||||
<td data-field="type" data-index="${index}">
|
|
||||||
<span class="badge ${rule.type === 'Allow' ? 'badge-yes' : 'badge-no'}">${rule.type}</span>
|
|
||||||
</td>
|
|
||||||
<td data-field="type_file" data-index="${index}">
|
|
||||||
${(rule.type_file || []).length > 0 ? (rule.type_file || []).map(f => `<code class="mini-tag">${f}</code>`).join(' ') : '<span class="empty-field">-</span>'}
|
|
||||||
</td>
|
|
||||||
<td data-field="path_access" data-index="${index}">
|
|
||||||
${(rule.path_access || []).length > 0 ? (rule.path_access || []).map(p => `<code class="mini-tag">${p}</code>`).join(' ') : '<span class="empty-field">-</span>'}
|
|
||||||
</td>
|
|
||||||
<td data-field="ip_list" data-index="${index}">
|
|
||||||
${(rule.ip_list || []).length > 0 ? (rule.ip_list || []).map(ip => `<code class="mini-tag">${ip}</code>`).join(' ') : '<span class="empty-field">-</span>'}
|
|
||||||
</td>
|
|
||||||
<td data-field="exceptions_dir" data-index="${index}">
|
|
||||||
${(rule.exceptions_dir || []).length > 0 ? (rule.exceptions_dir || []).map(e => `<code class="mini-tag">${e}</code>`).join(' ') : '<span class="empty-field">-</span>'}
|
|
||||||
</td>
|
|
||||||
<td data-field="url_error" data-index="${index}">
|
|
||||||
<code class="mini-tag">${rule.url_error || '404'}</code>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button class="icon-btn-small" data-action="remove-rule" data-index="${index}" title="Удалить"><i class="fas fa-trash"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
// Добавляем обработчики
|
|
||||||
this.attachRulesEventListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавить обработчики событий для правил
|
|
||||||
attachRulesEventListeners() {
|
|
||||||
// Drag & Drop
|
|
||||||
const rows = document.querySelectorAll('#vAccessTableBody tr[draggable]');
|
|
||||||
rows.forEach(row => {
|
|
||||||
row.addEventListener('dragstart', (e) => this.onDragStart(e));
|
|
||||||
row.addEventListener('dragover', (e) => this.onDragOver(e));
|
|
||||||
row.addEventListener('drop', (e) => this.onDrop(e));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Клик по ячейкам для редактирования
|
|
||||||
const cells = document.querySelectorAll('#vAccessTableBody td[data-field]');
|
|
||||||
cells.forEach(cell => {
|
|
||||||
cell.addEventListener('click', () => {
|
|
||||||
const field = cell.getAttribute('data-field');
|
|
||||||
const index = parseInt(cell.getAttribute('data-index'));
|
|
||||||
this.editRuleField(index, field);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Кнопки удаления
|
|
||||||
const removeButtons = document.querySelectorAll('[data-action="remove-rule"]');
|
|
||||||
removeButtons.forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const index = parseInt(btn.getAttribute('data-index'));
|
|
||||||
this.removeRule(index);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавить новое правило
|
|
||||||
addRule() {
|
|
||||||
this.vAccessRules.push({
|
|
||||||
type: 'Disable',
|
|
||||||
type_file: [],
|
|
||||||
path_access: [],
|
|
||||||
ip_list: [],
|
|
||||||
exceptions_dir: [],
|
|
||||||
url_error: '404'
|
|
||||||
});
|
|
||||||
|
|
||||||
this.switchTab('rules');
|
|
||||||
this.renderRulesList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удалить правило
|
|
||||||
removeRule(index) {
|
|
||||||
this.vAccessRules.splice(index, 1);
|
|
||||||
this.renderRulesList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Редактировать поле правила
|
|
||||||
editRuleField(index, field) {
|
|
||||||
const rule = this.vAccessRules[index];
|
|
||||||
|
|
||||||
if (field === 'type') {
|
|
||||||
// Переключаем тип
|
|
||||||
rule.type = rule.type === 'Allow' ? 'Disable' : 'Allow';
|
|
||||||
this.renderRulesList();
|
|
||||||
} else if (field === 'url_error') {
|
|
||||||
// Простой prompt для ошибки
|
|
||||||
const value = prompt('Страница ошибки:', rule.url_error || '404');
|
|
||||||
if (value !== null) {
|
|
||||||
rule.url_error = value;
|
|
||||||
this.renderRulesList();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Для массивов - показываем форму редактирования
|
|
||||||
this.showFieldEditor(index, field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Показать редактор поля
|
|
||||||
showFieldEditor(index, field) {
|
|
||||||
const rule = this.vAccessRules[index];
|
|
||||||
const fieldNames = {
|
|
||||||
'type_file': 'Расширения файлов',
|
|
||||||
'path_access': 'Пути доступа',
|
|
||||||
'ip_list': 'IP адреса',
|
|
||||||
'exceptions_dir': 'Исключения'
|
|
||||||
};
|
|
||||||
|
|
||||||
const placeholders = {
|
|
||||||
'type_file': '*.php',
|
|
||||||
'path_access': '/admin/*',
|
|
||||||
'ip_list': '127.0.0.1',
|
|
||||||
'exceptions_dir': '/public/*'
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = `
|
|
||||||
<div class="field-editor">
|
|
||||||
<div class="tag-input-wrapper" style="margin-bottom: 16px;">
|
|
||||||
<input type="text" class="form-input" id="fieldInput" placeholder="${placeholders[field]}">
|
|
||||||
<button class="action-btn" id="addFieldValueBtn"><i class="fas fa-plus"></i> Добавить</button>
|
|
||||||
</div>
|
|
||||||
<div class="tags-container" id="fieldTags">
|
|
||||||
${(rule[field] || []).map(value => `
|
|
||||||
<span class="tag">
|
|
||||||
${value}
|
|
||||||
<button class="tag-remove" data-value="${value}"><i class="fas fa-times"></i></button>
|
|
||||||
</span>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
<button class="action-btn" id="closeFieldEditorBtn" style="margin-top: 20px;">
|
|
||||||
<i class="fas fa-check"></i> Готово
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
this.editingField = { index, field };
|
|
||||||
modal.openFieldEditor(fieldNames[field], content);
|
|
||||||
|
|
||||||
// Добавляем обработчики
|
|
||||||
setTimeout(() => {
|
|
||||||
$('addFieldValueBtn')?.addEventListener('click', () => this.addFieldValue());
|
|
||||||
$('closeFieldEditorBtn')?.addEventListener('click', () => this.closeFieldEditor());
|
|
||||||
|
|
||||||
const removeButtons = document.querySelectorAll('#fieldTags .tag-remove');
|
|
||||||
removeButtons.forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const value = btn.getAttribute('data-value');
|
|
||||||
this.removeFieldValue(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавить значение в поле
|
|
||||||
addFieldValue() {
|
|
||||||
const input = $('fieldInput');
|
|
||||||
const value = input?.value.trim();
|
|
||||||
|
|
||||||
if (value && this.editingField) {
|
|
||||||
const { index, field } = this.editingField;
|
|
||||||
if (!this.vAccessRules[index][field]) {
|
|
||||||
this.vAccessRules[index][field] = [];
|
|
||||||
}
|
|
||||||
this.vAccessRules[index][field].push(value);
|
|
||||||
input.value = '';
|
|
||||||
this.showFieldEditor(index, field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удалить значение из поля
|
|
||||||
removeFieldValue(value) {
|
|
||||||
if (this.editingField) {
|
|
||||||
const { index, field } = this.editingField;
|
|
||||||
const arr = this.vAccessRules[index][field];
|
|
||||||
const idx = arr.indexOf(value);
|
|
||||||
if (idx > -1) {
|
|
||||||
arr.splice(idx, 1);
|
|
||||||
this.showFieldEditor(index, field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Закрыть редактор поля
|
|
||||||
closeFieldEditor() {
|
|
||||||
modal.closeFieldEditor();
|
|
||||||
this.renderRulesList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drag & Drop handlers
|
|
||||||
onDragStart(event) {
|
|
||||||
this.draggedIndex = parseInt(event.target.getAttribute('data-index'));
|
|
||||||
event.target.style.opacity = '0.5';
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragOver(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
onDrop(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const dropIndex = parseInt(event.target.closest('tr').getAttribute('data-index'));
|
|
||||||
|
|
||||||
if (this.draggedIndex === null || this.draggedIndex === dropIndex) return;
|
|
||||||
|
|
||||||
const draggedRule = this.vAccessRules[this.draggedIndex];
|
|
||||||
this.vAccessRules.splice(this.draggedIndex, 1);
|
|
||||||
this.vAccessRules.splice(dropIndex, 0, draggedRule);
|
|
||||||
|
|
||||||
this.draggedIndex = null;
|
|
||||||
this.renderRulesList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,130 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Custom Select Component
|
|
||||||
Кастомные выпадающие списки
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
import { $ } from '../utils/dom.js';
|
|
||||||
|
|
||||||
// Инициализация всех кастомных select'ов на странице
|
|
||||||
export function initCustomSelects() {
|
|
||||||
const selects = document.querySelectorAll('select.form-input');
|
|
||||||
selects.forEach(select => {
|
|
||||||
if (!select.dataset.customized) {
|
|
||||||
createCustomSelect(select);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создать кастомный select из нативного
|
|
||||||
function createCustomSelect(selectElement) {
|
|
||||||
// Помечаем как обработанный
|
|
||||||
selectElement.dataset.customized = 'true';
|
|
||||||
|
|
||||||
// Создаём контейнер
|
|
||||||
const wrapper = document.createElement('div');
|
|
||||||
wrapper.className = 'custom-select';
|
|
||||||
|
|
||||||
// Получаем выбранное значение
|
|
||||||
const selectedOption = selectElement.options[selectElement.selectedIndex];
|
|
||||||
const selectedText = selectedOption ? selectedOption.text : '';
|
|
||||||
|
|
||||||
// Создаём кнопку (видимая часть)
|
|
||||||
const button = document.createElement('div');
|
|
||||||
button.className = 'custom-select-trigger';
|
|
||||||
button.innerHTML = `
|
|
||||||
<span class="custom-select-value">${selectedText}</span>
|
|
||||||
<i class="fas fa-chevron-down custom-select-arrow"></i>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Создаём выпадающий список
|
|
||||||
const dropdown = document.createElement('div');
|
|
||||||
dropdown.className = 'custom-select-dropdown';
|
|
||||||
|
|
||||||
// Заполняем опции
|
|
||||||
Array.from(selectElement.options).forEach((option, index) => {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'custom-select-option';
|
|
||||||
item.textContent = option.text;
|
|
||||||
item.dataset.value = option.value;
|
|
||||||
item.dataset.index = index;
|
|
||||||
|
|
||||||
if (option.selected) {
|
|
||||||
item.classList.add('selected');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Клик по опции
|
|
||||||
item.addEventListener('click', () => {
|
|
||||||
selectOption(selectElement, wrapper, item, index);
|
|
||||||
});
|
|
||||||
|
|
||||||
dropdown.appendChild(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Клик по кнопке - открыть/закрыть
|
|
||||||
button.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleDropdown(wrapper);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Собираем вместе
|
|
||||||
wrapper.appendChild(button);
|
|
||||||
wrapper.appendChild(dropdown);
|
|
||||||
|
|
||||||
// Скрываем оригинальный select
|
|
||||||
selectElement.style.display = 'none';
|
|
||||||
|
|
||||||
// Вставляем кастомный select после оригинального
|
|
||||||
selectElement.parentNode.insertBefore(wrapper, selectElement.nextSibling);
|
|
||||||
|
|
||||||
// Закрываем при клике вне
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!wrapper.contains(e.target)) {
|
|
||||||
closeDropdown(wrapper);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Открыть/закрыть dropdown
|
|
||||||
function toggleDropdown(wrapper) {
|
|
||||||
const isOpen = wrapper.classList.contains('open');
|
|
||||||
|
|
||||||
// Закрываем все открытые
|
|
||||||
document.querySelectorAll('.custom-select.open').forEach(el => {
|
|
||||||
el.classList.remove('open');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
wrapper.classList.add('open');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Закрыть dropdown
|
|
||||||
function closeDropdown(wrapper) {
|
|
||||||
wrapper.classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Выбрать опцию
|
|
||||||
function selectOption(selectElement, wrapper, optionElement, index) {
|
|
||||||
// Обновляем оригинальный select
|
|
||||||
selectElement.selectedIndex = index;
|
|
||||||
|
|
||||||
// Триггерим событие change
|
|
||||||
const event = new Event('change', { bubbles: true });
|
|
||||||
selectElement.dispatchEvent(event);
|
|
||||||
|
|
||||||
// Обновляем UI
|
|
||||||
const valueSpan = wrapper.querySelector('.custom-select-value');
|
|
||||||
valueSpan.textContent = optionElement.textContent;
|
|
||||||
|
|
||||||
// Убираем selected у всех опций
|
|
||||||
wrapper.querySelectorAll('.custom-select-option').forEach(opt => {
|
|
||||||
opt.classList.remove('selected');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Добавляем selected к выбранной
|
|
||||||
optionElement.classList.add('selected');
|
|
||||||
|
|
||||||
// Закрываем dropdown
|
|
||||||
closeDropdown(wrapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Modal Manager
|
|
||||||
Управление модальными окнами
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
import { $, addClass, removeClass } from '../utils/dom.js';
|
|
||||||
|
|
||||||
// Класс для управления модальными окнами
|
|
||||||
export class Modal {
|
|
||||||
constructor() {
|
|
||||||
this.overlay = $('modalOverlay');
|
|
||||||
this.title = $('modalTitle');
|
|
||||||
this.content = $('modalContent');
|
|
||||||
this.closeBtn = $('modalCloseBtn');
|
|
||||||
this.cancelBtn = $('modalCancelBtn');
|
|
||||||
this.saveBtn = $('modalSaveBtn');
|
|
||||||
this.fieldEditorOverlay = $('fieldEditorOverlay');
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
if (this.closeBtn) {
|
|
||||||
this.closeBtn.addEventListener('click', () => this.close());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.cancelBtn) {
|
|
||||||
this.cancelBtn.addEventListener('click', () => this.close());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.saveBtn) {
|
|
||||||
this.saveBtn.addEventListener('click', () => {
|
|
||||||
if (window.saveModalData) {
|
|
||||||
window.saveModalData();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.overlay) {
|
|
||||||
this.overlay.addEventListener('click', (e) => {
|
|
||||||
if (e.target === this.overlay) {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Открыть модальное окно
|
|
||||||
open(title, htmlContent) {
|
|
||||||
if (this.title) this.title.textContent = title;
|
|
||||||
if (this.content) this.content.innerHTML = htmlContent;
|
|
||||||
if (this.overlay) addClass(this.overlay, 'show');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Закрыть модальное окно
|
|
||||||
close() {
|
|
||||||
if (this.overlay) removeClass(this.overlay, 'show');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Установить обработчик сохранения
|
|
||||||
onSave(callback) {
|
|
||||||
if (this.saveBtn) {
|
|
||||||
this.saveBtn.onclick = callback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Открыть редактор поля
|
|
||||||
openFieldEditor(title, htmlContent) {
|
|
||||||
const fieldTitle = $('fieldEditorTitle');
|
|
||||||
const fieldContent = $('fieldEditorContent');
|
|
||||||
|
|
||||||
if (fieldTitle) fieldTitle.textContent = title;
|
|
||||||
if (fieldContent) fieldContent.innerHTML = htmlContent;
|
|
||||||
if (this.fieldEditorOverlay) addClass(this.fieldEditorOverlay, 'show');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Закрыть редактор поля
|
|
||||||
closeFieldEditor() {
|
|
||||||
if (this.fieldEditorOverlay) removeClass(this.fieldEditorOverlay, 'show');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Глобальный экземпляр модального окна
|
|
||||||
export const modal = new Modal();
|
|
||||||
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Navigation
|
|
||||||
Управление навигацией
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
import { $, $$, hide, show, removeClass, addClass } from '../utils/dom.js';
|
|
||||||
|
|
||||||
// Класс для управления навигацией
|
|
||||||
export class Navigation {
|
|
||||||
constructor() {
|
|
||||||
this.navItems = $$('.nav-item[data-page]');
|
|
||||||
this.sections = {
|
|
||||||
services: $('sectionServices'),
|
|
||||||
sites: $('sectionSites'),
|
|
||||||
proxy: $('sectionProxy'),
|
|
||||||
settings: $('sectionSettings'),
|
|
||||||
vaccess: $('sectionVAccessEditor'),
|
|
||||||
addSite: $('sectionAddSite')
|
|
||||||
};
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.navItems.forEach(item => {
|
|
||||||
item.addEventListener('click', () => {
|
|
||||||
const page = item.dataset.page;
|
|
||||||
this.navigate(page, item);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate(page, clickedItem) {
|
|
||||||
// Убираем active со всех навигационных элементов
|
|
||||||
this.navItems.forEach(nav => removeClass(nav, 'active'));
|
|
||||||
addClass(clickedItem, 'active');
|
|
||||||
|
|
||||||
// Скрываем все секции
|
|
||||||
this.hideAllSections();
|
|
||||||
|
|
||||||
// Показываем нужные секции по имени страницы
|
|
||||||
switch (page) {
|
|
||||||
case 'dashboard':
|
|
||||||
show(this.sections.services);
|
|
||||||
show(this.sections.sites);
|
|
||||||
show(this.sections.proxy);
|
|
||||||
break;
|
|
||||||
case 'settings':
|
|
||||||
show(this.sections.settings);
|
|
||||||
if (window.loadConfig) {
|
|
||||||
window.loadConfig();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hideAllSections() {
|
|
||||||
Object.values(this.sections).forEach(section => {
|
|
||||||
if (section) hide(section);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showDashboard() {
|
|
||||||
this.hideAllSections();
|
|
||||||
show(this.sections.services);
|
|
||||||
show(this.sections.sites);
|
|
||||||
show(this.sections.proxy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Notification System
|
|
||||||
Система уведомлений
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
import { $, addClass, removeClass } from '../utils/dom.js';
|
|
||||||
|
|
||||||
// Класс для управления уведомлениями
|
|
||||||
export class NotificationManager {
|
|
||||||
constructor() {
|
|
||||||
this.container = $('notification');
|
|
||||||
this.loader = $('appLoader');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Показать уведомление
|
|
||||||
show(message, type = 'success', duration = 1000) {
|
|
||||||
if (!this.container) return;
|
|
||||||
|
|
||||||
const icon = type === 'success'
|
|
||||||
? '<i class="fas fa-check-circle"></i>'
|
|
||||||
: '<i class="fas fa-exclamation-circle"></i>';
|
|
||||||
|
|
||||||
this.container.innerHTML = `
|
|
||||||
<div class="notification-content">
|
|
||||||
<div class="notification-icon">${icon}</div>
|
|
||||||
<div class="notification-text">${message}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
this.container.className = `notification show ${type}`;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
removeClass(this.container, 'show');
|
|
||||||
}, duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Показать успешное уведомление
|
|
||||||
success(message, duration = 1000) {
|
|
||||||
this.show(message, 'success', duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Показать уведомление об ошибке
|
|
||||||
error(message, duration = 2000) {
|
|
||||||
this.show(message, 'error', duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Скрыть загрузчик приложения
|
|
||||||
hideLoader() {
|
|
||||||
if (!this.loader) return;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
addClass(this.loader, 'hide');
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.loader.parentNode) {
|
|
||||||
this.loader.remove();
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Глобальный экземпляр менеджера уведомлений
|
|
||||||
export const notification = new NotificationManager();
|
|
||||||
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Window Controls
|
|
||||||
Управление окном приложения
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
import { $, addClass } from '../utils/dom.js';
|
|
||||||
|
|
||||||
// Класс для управления окном
|
|
||||||
export class WindowControls {
|
|
||||||
constructor() {
|
|
||||||
this.minimizeBtn = $('minimizeBtn');
|
|
||||||
this.maximizeBtn = $('maximizeBtn');
|
|
||||||
this.closeBtn = $('closeBtn');
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
if (this.minimizeBtn) {
|
|
||||||
this.minimizeBtn.addEventListener('click', () => this.minimize());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.maximizeBtn) {
|
|
||||||
this.maximizeBtn.addEventListener('click', () => this.maximize());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.closeBtn) {
|
|
||||||
this.closeBtn.addEventListener('click', () => this.close());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
minimize() {
|
|
||||||
if (window.runtime?.WindowMinimise) {
|
|
||||||
window.runtime.WindowMinimise();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
maximize() {
|
|
||||||
if (window.runtime?.WindowToggleMaximise) {
|
|
||||||
window.runtime.WindowToggleMaximise();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
if (window.runtime?.Quit) {
|
|
||||||
window.runtime.Quit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
DOM Utilities
|
|
||||||
Утилиты для работы с DOM
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
// Получить элемент по ID
|
|
||||||
export function $(id) {
|
|
||||||
return document.getElementById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получить все элементы по селектору
|
|
||||||
export function $$(selector, parent = document) {
|
|
||||||
return parent.querySelectorAll(selector);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Показать элемент
|
|
||||||
export function show(element) {
|
|
||||||
const el = typeof element === 'string' ? $(element) : element;
|
|
||||||
if (el) el.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Скрыть элемент
|
|
||||||
export function hide(element) {
|
|
||||||
const el = typeof element === 'string' ? $(element) : element;
|
|
||||||
if (el) el.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Переключить видимость элемента
|
|
||||||
export function toggle(element) {
|
|
||||||
const el = typeof element === 'string' ? $(element) : element;
|
|
||||||
if (el) {
|
|
||||||
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавить класс
|
|
||||||
export function addClass(element, className) {
|
|
||||||
const el = typeof element === 'string' ? $(element) : element;
|
|
||||||
if (el) el.classList.add(className);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удалить класс
|
|
||||||
export function removeClass(element, className) {
|
|
||||||
const el = typeof element === 'string' ? $(element) : element;
|
|
||||||
if (el) el.classList.remove(className);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Переключить класс
|
|
||||||
export function toggleClass(element, className) {
|
|
||||||
const el = typeof element === 'string' ? $(element) : element;
|
|
||||||
if (el) el.classList.toggle(className);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
Helper Utilities
|
|
||||||
Вспомогательные функции
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
// Ждёт указанное время
|
|
||||||
export function sleep(ms) {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce функция
|
|
||||||
export function debounce(func, wait) {
|
|
||||||
let timeout;
|
|
||||||
return function executedFunction(...args) {
|
|
||||||
const later = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
func(...args);
|
|
||||||
};
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(later, wait);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяет доступность Wails API
|
|
||||||
export function isWailsAvailable() {
|
|
||||||
return typeof window.go !== 'undefined' &&
|
|
||||||
window.go?.admin?.App !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,915 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>vServer Admin Panel</title>
|
|
||||||
<link rel="stylesheet" href="assets/css/local_lib/all.min.css">
|
|
||||||
<link rel="stylesheet" href="assets/css/main.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="window-controls" id="windowControls">
|
|
||||||
<div class="title-bar">
|
|
||||||
<div class="title-bar-left">
|
|
||||||
<div class="app-logo">
|
|
||||||
<span class="logo-icon">🚀</span>
|
|
||||||
<span class="logo-text">vServer</span>
|
|
||||||
</div>
|
|
||||||
<div class="server-status" id="serverStatus">
|
|
||||||
<span class="status-indicator status-online"></span>
|
|
||||||
<span class="status-text">Сервер запущен</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="title-bar-right">
|
|
||||||
<button class="server-control-btn" id="serverControlBtn">
|
|
||||||
<i class="fas fa-power-off"></i>
|
|
||||||
<span class="btn-text">Остановить</span>
|
|
||||||
</button>
|
|
||||||
<button class="window-btn minimize-btn" id="minimizeBtn" title="Свернуть">
|
|
||||||
<i class="fas fa-window-minimize"></i>
|
|
||||||
</button>
|
|
||||||
<button class="window-btn maximize-btn" id="maximizeBtn" title="Развернуть">
|
|
||||||
<i class="far fa-window-maximize"></i>
|
|
||||||
</button>
|
|
||||||
<button class="window-btn close-btn" id="closeBtn" title="Закрыть">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="sidebar">
|
|
||||||
<nav class="sidebar-nav">
|
|
||||||
<button class="nav-item active" data-page="dashboard" title="Главная">
|
|
||||||
<i class="fas fa-home"></i>
|
|
||||||
</button>
|
|
||||||
<button class="nav-item" data-page="settings" title="Настройки">
|
|
||||||
<i class="fas fa-cog"></i>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
|
|
||||||
<main class="main-content">
|
|
||||||
<section class="section" id="sectionServices">
|
|
||||||
<h2 class="section-title">Статус сервисов</h2>
|
|
||||||
<div class="services-grid" id="servicesGrid">
|
|
||||||
<div class="service-card">
|
|
||||||
<div class="service-header">
|
|
||||||
<h3 class="service-name"><i class="fas fa-globe"></i> HTTP</h3>
|
|
||||||
<span class="badge badge-pending">Запуск</span>
|
|
||||||
</div>
|
|
||||||
<div class="service-info">
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Порт:</span>
|
|
||||||
<span class="info-value">80</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="service-card">
|
|
||||||
<div class="service-header">
|
|
||||||
<h3 class="service-name"><i class="fas fa-lock"></i> HTTPS</h3>
|
|
||||||
<span class="badge badge-pending">Запуск</span>
|
|
||||||
</div>
|
|
||||||
<div class="service-info">
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Порт:</span>
|
|
||||||
<span class="info-value">443</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="service-card">
|
|
||||||
<div class="service-header">
|
|
||||||
<h3 class="service-name"><i class="fas fa-database"></i> MySQL</h3>
|
|
||||||
<span class="badge badge-pending">Запуск</span>
|
|
||||||
</div>
|
|
||||||
<div class="service-info">
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Порт:</span>
|
|
||||||
<span class="info-value">3306</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="service-card">
|
|
||||||
<div class="service-header">
|
|
||||||
<h3 class="service-name"><i class="fab fa-php"></i> PHP</h3>
|
|
||||||
<span class="badge badge-pending">Запуск</span>
|
|
||||||
</div>
|
|
||||||
<div class="service-info">
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Порты:</span>
|
|
||||||
<span class="info-value">8000-8003</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="service-card">
|
|
||||||
<div class="service-header">
|
|
||||||
<h3 class="service-name"><i class="fas fa-exchange-alt"></i> Proxy</h3>
|
|
||||||
<span class="badge badge-pending">Запуск</span>
|
|
||||||
</div>
|
|
||||||
<div class="service-info">
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Правил:</span>
|
|
||||||
<span class="info-value">0 из 1</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section" id="sectionSites">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
|
||||||
<h2 class="section-title" style="margin-bottom: 0;">Список сайтов</h2>
|
|
||||||
<button class="action-btn" id="addSiteBtn">
|
|
||||||
<i class="fas fa-plus"></i>
|
|
||||||
<span>Добавить сайт</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="table-container">
|
|
||||||
<table class="data-table" id="sitesTable">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Имя</th>
|
|
||||||
<th>Host</th>
|
|
||||||
<th>Alias</th>
|
|
||||||
<th>Статус</th>
|
|
||||||
<th>Root File</th>
|
|
||||||
<th>Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>Локальный сайт</td>
|
|
||||||
<td><code class="clickable-link" onclick="openTestLink('http://127.0.0.1')">127.0.0.1 <i class="fas fa-external-link-alt"></i></code></td>
|
|
||||||
<td><code>localhost</code></td>
|
|
||||||
<td><span class="badge badge-online">active</span></td>
|
|
||||||
<td><code>index.html</code></td>
|
|
||||||
<td>
|
|
||||||
<button class="icon-btn" onclick="openSiteFolder('127.0.0.1')" title="Открыть папку"><i class="fas fa-folder-open"></i></button>
|
|
||||||
<button class="icon-btn" onclick="editVAccess('127.0.0.1', false)" title="vAccess"><i class="fas fa-shield-alt"></i></button>
|
|
||||||
<button class="icon-btn" onclick="editTestSite(0)" title="Редактировать"><i class="fas fa-edit"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Тестовый проект</td>
|
|
||||||
<td><code class="clickable-link" onclick="openTestLink('http://test.local')">test.local <i class="fas fa-external-link-alt"></i></code></td>
|
|
||||||
<td><code>*.test.local, test.com</code></td>
|
|
||||||
<td><span class="badge badge-online">active</span></td>
|
|
||||||
<td><code>index.php</code></td>
|
|
||||||
<td>
|
|
||||||
<button class="icon-btn" onclick="openSiteFolder('test.local')" title="Открыть папку"><i class="fas fa-folder-open"></i></button>
|
|
||||||
<button class="icon-btn" onclick="editVAccess('test.local', false)" title="vAccess"><i class="fas fa-shield-alt"></i></button>
|
|
||||||
<button class="icon-btn" onclick="editTestSite(1)" title="Редактировать"><i class="fas fa-edit"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>API сервис</td>
|
|
||||||
<td><code class="clickable-link" onclick="openTestLink('http://api.example.com')">api.example.com <i class="fas fa-external-link-alt"></i></code></td>
|
|
||||||
<td><code>*.api.example.com</code></td>
|
|
||||||
<td><span class="badge badge-offline">inactive</span></td>
|
|
||||||
<td><code>index.php</code></td>
|
|
||||||
<td>
|
|
||||||
<button class="icon-btn" onclick="openSiteFolder('api.example.com')" title="Открыть папку"><i class="fas fa-folder-open"></i></button>
|
|
||||||
<button class="icon-btn" onclick="editVAccess('api.example.com', false)" title="vAccess"><i class="fas fa-shield-alt"></i></button>
|
|
||||||
<button class="icon-btn" onclick="editTestSite(2)" title="Редактировать"><i class="fas fa-edit"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section" id="sectionProxy">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
|
||||||
<h2 class="section-title" style="margin-bottom: 0;">Прокси сервисы</h2>
|
|
||||||
<button class="action-btn" id="addProxyBtn">
|
|
||||||
<i class="fas fa-plus"></i>
|
|
||||||
<span>Добавить прокси</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="table-container">
|
|
||||||
<table class="data-table" id="proxyTable">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Внешний домен</th>
|
|
||||||
<th>Локальный адрес</th>
|
|
||||||
<th>HTTPS</th>
|
|
||||||
<th>Auto HTTPS</th>
|
|
||||||
<th>Статус</th>
|
|
||||||
<th>Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><code class="clickable-link" onclick="openTestLink('https://git.example.ru')">git.example.ru <i class="fas fa-external-link-alt"></i></code></td>
|
|
||||||
<td><code>127.0.0.1:3333</code></td>
|
|
||||||
<td><span class="badge badge-no">HTTP</span></td>
|
|
||||||
<td><span class="badge badge-yes">Да</span></td>
|
|
||||||
<td><span class="badge badge-online">active</span></td>
|
|
||||||
<td>
|
|
||||||
<button class="icon-btn" onclick="editVAccess('git.example.ru', true)" title="vAccess"><i class="fas fa-shield-alt"></i></button>
|
|
||||||
<button class="icon-btn" onclick="editTestProxy(0)" title="Редактировать"><i class="fas fa-edit"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code class="clickable-link" onclick="openTestLink('http://api.example.com')">api.example.com <i class="fas fa-external-link-alt"></i></code></td>
|
|
||||||
<td><code>127.0.0.1:8080</code></td>
|
|
||||||
<td><span class="badge badge-yes">HTTPS</span></td>
|
|
||||||
<td><span class="badge badge-no">Нет</span></td>
|
|
||||||
<td><span class="badge badge-online">active</span></td>
|
|
||||||
<td>
|
|
||||||
<button class="icon-btn" onclick="editVAccess('api.example.com', true)" title="vAccess"><i class="fas fa-shield-alt"></i></button>
|
|
||||||
<button class="icon-btn" onclick="editTestProxy(1)" title="Редактировать"><i class="fas fa-edit"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><code class="clickable-link" onclick="openTestLink('http://test.example.net')">test.example.net <i class="fas fa-external-link-alt"></i></code></td>
|
|
||||||
<td><code>127.0.0.1:5000</code></td>
|
|
||||||
<td><span class="badge badge-no">HTTP</span></td>
|
|
||||||
<td><span class="badge badge-no">Нет</span></td>
|
|
||||||
<td><span class="badge badge-offline">disabled</span></td>
|
|
||||||
<td>
|
|
||||||
<button class="icon-btn" onclick="editVAccess('test.example.net', true)" title="vAccess"><i class="fas fa-shield-alt"></i></button>
|
|
||||||
<button class="icon-btn" onclick="editTestProxy(2)" title="Редактировать"><i class="fas fa-edit"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section" id="sectionSettings" style="display: none;">
|
|
||||||
<div class="settings-header">
|
|
||||||
<h2 class="section-title">Настройки серверов</h2>
|
|
||||||
<button class="action-btn save-btn" id="saveSettingsBtn">
|
|
||||||
<i class="fas fa-save"></i>
|
|
||||||
<span>Сохранить и перезапустить</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-grid">
|
|
||||||
<div class="settings-card">
|
|
||||||
<h3 class="settings-card-title"><i class="fas fa-database"></i> MySQL сервер</h3>
|
|
||||||
<div class="settings-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Host адрес:</label>
|
|
||||||
<input type="text" class="form-input" id="mysqlHost" placeholder="127.0.0.1">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Порт:</label>
|
|
||||||
<input type="number" class="form-input" id="mysqlPort" placeholder="3306">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-card">
|
|
||||||
<h3 class="settings-card-title"><i class="fab fa-php"></i> PHP сервер</h3>
|
|
||||||
<div class="settings-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Host адрес:</label>
|
|
||||||
<input type="text" class="form-input" id="phpHost" placeholder="localhost">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Порт:</label>
|
|
||||||
<input type="number" class="form-input" id="phpPort" placeholder="8000">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-card">
|
|
||||||
<h3 class="settings-card-title"><i class="fas fa-network-wired"></i> Proxy Manager</h3>
|
|
||||||
<div class="settings-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Статус:</label>
|
|
||||||
<div class="toggle-wrapper">
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" id="proxyEnabled">
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="toggle-label">Proxy Manager</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-info">
|
|
||||||
⚡ Применяется моментально без перезапуска серверов. При выключении все прокси правила будут отключены.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-card">
|
|
||||||
<h3 class="settings-card-title"><i class="fas fa-certificate"></i> Cert Manager</h3>
|
|
||||||
<div class="settings-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Статус:</label>
|
|
||||||
<div class="toggle-wrapper">
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" id="acmeEnabled">
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="toggle-label">Cert Manager</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-info">
|
|
||||||
🔐 Автоматическое получение SSL сертификатов от Let's Encrypt для доменов с включённым "Авто SSL".
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section" id="sectionVAccessEditor" style="display: none;">
|
|
||||||
<div class="vaccess-page">
|
|
||||||
<!-- Хлебные крошки с вкладками -->
|
|
||||||
<div class="breadcrumbs" id="vAccessBreadcrumbs">
|
|
||||||
<div class="breadcrumbs-left">
|
|
||||||
<button class="breadcrumb-item" onclick="backToMain()">
|
|
||||||
<i class="fas fa-arrow-left"></i> Назад
|
|
||||||
</button>
|
|
||||||
<span class="breadcrumb-separator">/</span>
|
|
||||||
<span class="breadcrumb-item active" id="breadcrumbHost">example.com</span>
|
|
||||||
</div>
|
|
||||||
<div class="breadcrumbs-tabs">
|
|
||||||
<button class="vaccess-tab active" data-tab="rules" onclick="switchVAccessTab('rules')">
|
|
||||||
<i class="fas fa-list"></i>
|
|
||||||
<span>Правила доступа</span>
|
|
||||||
</button>
|
|
||||||
<button class="vaccess-tab" data-tab="help" onclick="switchVAccessTab('help')">
|
|
||||||
<i class="fas fa-question-circle"></i>
|
|
||||||
<span>Инструкция</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Заголовок с кнопками -->
|
|
||||||
<div class="vaccess-header">
|
|
||||||
<div class="vaccess-title-block">
|
|
||||||
<h2 class="vaccess-title">
|
|
||||||
<i class="fas fa-shield-alt"></i>
|
|
||||||
<span>Правила доступа vAccess</span>
|
|
||||||
</h2>
|
|
||||||
<p class="vaccess-subtitle" id="vAccessSubtitle">Управление правилами доступа для сайта</p>
|
|
||||||
</div>
|
|
||||||
<div class="vaccess-actions">
|
|
||||||
<button class="action-btn save-btn" id="saveVAccessBtn">
|
|
||||||
<i class="fas fa-save"></i>
|
|
||||||
<span>Сохранить изменения</span>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn" onclick="addVAccessRule()">
|
|
||||||
<i class="fas fa-plus"></i>
|
|
||||||
<span>Добавить правило</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Контент вкладки "Правила" -->
|
|
||||||
<div class="vaccess-tab-content" id="vAccessRulesTab">
|
|
||||||
<div class="vaccess-rules-container">
|
|
||||||
<table class="vaccess-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="col-drag"></th>
|
|
||||||
<th class="col-type">Тип</th>
|
|
||||||
<th class="col-files">Расширения</th>
|
|
||||||
<th class="col-paths">Пути доступа</th>
|
|
||||||
<th class="col-ips">IP адреса</th>
|
|
||||||
<th class="col-exceptions">Исключения</th>
|
|
||||||
<th class="col-error">Ошибка</th>
|
|
||||||
<th class="col-actions"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="vAccessTableBody">
|
|
||||||
<!-- Правила будут добавлены динамически -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div class="vaccess-empty" id="vAccessEmpty" style="display: none;">
|
|
||||||
<div class="empty-icon">
|
|
||||||
<i class="fas fa-shield-alt"></i>
|
|
||||||
</div>
|
|
||||||
<h3>Нет правил доступа</h3>
|
|
||||||
<p>Добавьте первое правило, чтобы начать управление доступом</p>
|
|
||||||
<button class="action-btn" onclick="addVAccessRule()">
|
|
||||||
<i class="fas fa-plus"></i>
|
|
||||||
<span>Создать правило</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Контент вкладки "Инструкция" -->
|
|
||||||
<div class="vaccess-tab-content" id="vAccessHelpTab" style="display: none;">
|
|
||||||
<div class="vaccess-help">
|
|
||||||
<div class="help-card">
|
|
||||||
<h3><i class="fas fa-info-circle"></i> Принцип работы</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Правила проверяются <strong>сверху вниз</strong> по порядку</li>
|
|
||||||
<li>Первое подходящее правило срабатывает и завершает проверку</li>
|
|
||||||
<li>Если ни одно правило не сработало - доступ <strong>разрешён</strong></li>
|
|
||||||
<li>Перетаскивайте правила за <i class="fas fa-grip-vertical"></i> чтобы изменить порядок</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-card">
|
|
||||||
<h3><i class="fas fa-sliders-h"></i> Параметры правил</h3>
|
|
||||||
<div class="help-params">
|
|
||||||
<div class="help-param">
|
|
||||||
<strong>type:</strong>
|
|
||||||
<p><span class="badge badge-yes">Allow</span> (разрешить) или <span class="badge badge-no">Disable</span> (запретить)</p>
|
|
||||||
</div>
|
|
||||||
<div class="help-param">
|
|
||||||
<strong>Расширения файлов:</strong>
|
|
||||||
<p>Список расширений через запятую (<code>*.php</code>, <code>*.exe</code>)</p>
|
|
||||||
</div>
|
|
||||||
<div class="help-param">
|
|
||||||
<strong>Пути доступа:</strong>
|
|
||||||
<p>Список путей через запятую (<code>/admin/*</code>, <code>/api/*</code>)</p>
|
|
||||||
</div>
|
|
||||||
<div class="help-param">
|
|
||||||
<strong>IP адреса:</strong>
|
|
||||||
<p>Список IP адресов через запятую (<code>192.168.1.1</code>, <code>10.0.0.5</code>)</p>
|
|
||||||
<p class="help-warning"><i class="fas fa-exclamation-triangle"></i> Используется реальный IP соединения (не заголовки прокси!)</p>
|
|
||||||
</div>
|
|
||||||
<div class="help-param">
|
|
||||||
<strong>Исключения:</strong>
|
|
||||||
<p>Пути-исключения через запятую (<code>/bot/*</code>, <code>/public/*</code>). Правило НЕ применяется к этим путям</p>
|
|
||||||
</div>
|
|
||||||
<div class="help-param">
|
|
||||||
<strong>Страница ошибки:</strong>
|
|
||||||
<p>Куда перенаправить при блокировке:</p>
|
|
||||||
<ul>
|
|
||||||
<li><code>404</code> - стандартная страница ошибки</li>
|
|
||||||
<li><code>https://site.com</code> - внешний редирект</li>
|
|
||||||
<li><code>/error.html</code> - локальная страница</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-card">
|
|
||||||
<h3><i class="fas fa-search"></i> Паттерны</h3>
|
|
||||||
<div class="help-patterns">
|
|
||||||
<div class="pattern-item">
|
|
||||||
<code>*.ext</code>
|
|
||||||
<span>Любой файл с расширением .ext</span>
|
|
||||||
</div>
|
|
||||||
<div class="pattern-item">
|
|
||||||
<code>no_extension</code>
|
|
||||||
<span>Файлы без расширения (например: /api/users, /admin)</span>
|
|
||||||
</div>
|
|
||||||
<div class="pattern-item">
|
|
||||||
<code>/path/*</code>
|
|
||||||
<span>Все файлы в папке /path/ и подпапках</span>
|
|
||||||
</div>
|
|
||||||
<div class="pattern-item">
|
|
||||||
<code>/file.php</code>
|
|
||||||
<span>Конкретный файл</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-card help-examples">
|
|
||||||
<h3><i class="fas fa-lightbulb"></i> Примеры правил</h3>
|
|
||||||
|
|
||||||
<div class="help-example">
|
|
||||||
<h4>1. Запретить выполнение PHP в uploads</h4>
|
|
||||||
<div class="example-rule">
|
|
||||||
<div><strong>Тип:</strong> <span class="badge badge-no">Disable</span></div>
|
|
||||||
<div><strong>Расширения:</strong> <code>*.php</code></div>
|
|
||||||
<div><strong>Пути:</strong> <code>/uploads/*</code></div>
|
|
||||||
<div><strong>Ошибка:</strong> <code>404</code></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-example">
|
|
||||||
<h4>2. Разрешить админку только с определённых IP</h4>
|
|
||||||
<div class="example-rule">
|
|
||||||
<div><strong>Тип:</strong> <span class="badge badge-yes">Allow</span></div>
|
|
||||||
<div><strong>Пути:</strong> <code>/admin/*</code></div>
|
|
||||||
<div><strong>IP:</strong> <code>192.168.1.100, 127.0.0.1</code></div>
|
|
||||||
<div><strong>Ошибка:</strong> <code>404</code></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-example">
|
|
||||||
<h4>3. Блокировать определённые IP для всего сайта</h4>
|
|
||||||
<div class="example-rule">
|
|
||||||
<div><strong>Тип:</strong> <span class="badge badge-no">Disable</span></div>
|
|
||||||
<div><strong>IP:</strong> <code>192.168.1.50, 10.0.0.99</code></div>
|
|
||||||
<div><strong>Ошибка:</strong> <code>https://google.com</code></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section" id="sectionAddSite" style="display: none;">
|
|
||||||
<div class="vaccess-page">
|
|
||||||
<!-- Хлебные крошки -->
|
|
||||||
<div class="breadcrumbs">
|
|
||||||
<div class="breadcrumbs-left">
|
|
||||||
<button class="breadcrumb-item" onclick="backToMainFromAddSite()">
|
|
||||||
<i class="fas fa-arrow-left"></i> Назад
|
|
||||||
</button>
|
|
||||||
<span class="breadcrumb-separator">/</span>
|
|
||||||
<span class="breadcrumb-item active">Добавить новый сайт</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Заголовок -->
|
|
||||||
<div class="vaccess-header">
|
|
||||||
<div class="vaccess-title-block">
|
|
||||||
<h2 class="vaccess-title">
|
|
||||||
<i class="fas fa-plus-circle"></i>
|
|
||||||
<span>Создание нового сайта</span>
|
|
||||||
</h2>
|
|
||||||
<p class="vaccess-subtitle">Заполните информацию о сайте и при необходимости загрузите SSL сертификаты</p>
|
|
||||||
</div>
|
|
||||||
<div class="vaccess-actions">
|
|
||||||
<button class="action-btn save-btn" id="createSiteBtn">
|
|
||||||
<i class="fas fa-check"></i>
|
|
||||||
<span>Создать сайт</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Форма создания сайта -->
|
|
||||||
<div class="vaccess-tab-content">
|
|
||||||
<div class="site-creator-form">
|
|
||||||
|
|
||||||
<!-- Единая форма -->
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="settings-form">
|
|
||||||
|
|
||||||
<!-- Основная информация -->
|
|
||||||
<h3 class="form-subsection-title"><i class="fas fa-info-circle"></i> Основная информация</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Название сайта: <span style="color: #e74c3c;">*</span></label>
|
|
||||||
<input type="text" class="form-input" id="newSiteName" placeholder="Мой новый сайт">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Host (домен): <span style="color: #e74c3c;">*</span></label>
|
|
||||||
<input type="text" class="form-input" id="newSiteHost" placeholder="example.com">
|
|
||||||
<small style="color: #95a5a6; display: block; margin-top: 5px;">
|
|
||||||
<i class="fas fa-info-circle"></i> Введите домен без протокола (например: example.com или 192.168.1.100)
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Alias (псевдонимы):</label>
|
|
||||||
<input type="text" class="form-input" id="newSiteAliasInput" placeholder="*.example.com, www.example.com, alias.com">
|
|
||||||
<small style="color: #95a5a6; display: block; margin-top: 5px;">
|
|
||||||
<i class="fas fa-info-circle"></i> Введите псевдонимы через запятую
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Root файл: <span style="color: #e74c3c;">*</span></label>
|
|
||||||
<select class="form-input" id="newSiteRootFile">
|
|
||||||
<option value="index.html">index.html</option>
|
|
||||||
<option value="index.php">index.php</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Статус:</label>
|
|
||||||
<select class="form-input" id="newSiteStatus">
|
|
||||||
<option value="active">Active (Активен)</option>
|
|
||||||
<option value="inactive">Inactive (Отключен)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Root file routing:</label>
|
|
||||||
<div class="toggle-wrapper">
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" id="newSiteRouting" checked>
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="toggle-label">Включён</span>
|
|
||||||
</div>
|
|
||||||
<small style="color: #95a5a6; display: block; margin-top: 5px;">
|
|
||||||
<i class="fas fa-info-circle"></i> Если включено, все запросы к несуществующим файлам будут перенаправляться на root файл
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SSL Сертификаты -->
|
|
||||||
<h3 class="form-subsection-title"><i class="fas fa-lock"></i> SSL Сертификаты (опционально)</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Режим сертификата:</label>
|
|
||||||
<select class="form-input" id="certMode" onchange="toggleCertUpload()">
|
|
||||||
<option value="none">Без сертификата (fallback)</option>
|
|
||||||
<option value="auto">Автоматическое создание сертификата</option>
|
|
||||||
<option value="upload">Загрузить файлы сертификата</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="certUploadBlock" style="display: none;">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Certificate (*.crt): <span style="color: #e74c3c;">*</span></label>
|
|
||||||
<div class="file-upload-wrapper">
|
|
||||||
<input type="file" class="file-input" id="certFile" accept=".crt,.pem" onchange="handleCertFileSelect(this, 'certificate')">
|
|
||||||
<label for="certFile" class="file-upload-btn">
|
|
||||||
<i class="fas fa-file-upload"></i>
|
|
||||||
<span id="certFileName">Выберите файл...</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="certFileStatus" style="margin-top: 8px;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Private Key (*.key): <span style="color: #e74c3c;">*</span></label>
|
|
||||||
<div class="file-upload-wrapper">
|
|
||||||
<input type="file" class="file-input" id="keyFile" accept=".key,.pem" onchange="handleCertFileSelect(this, 'privatekey')">
|
|
||||||
<label for="keyFile" class="file-upload-btn">
|
|
||||||
<i class="fas fa-file-upload"></i>
|
|
||||||
<span id="keyFileName">Выберите файл...</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="keyFileStatus" style="margin-top: 8px;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">CA Bundle (*.crt):</label>
|
|
||||||
<div class="file-upload-wrapper">
|
|
||||||
<input type="file" class="file-input" id="caFile" accept=".crt,.pem" onchange="handleCertFileSelect(this, 'cabundle')">
|
|
||||||
<label for="caFile" class="file-upload-btn">
|
|
||||||
<i class="fas fa-file-upload"></i>
|
|
||||||
<span id="caFileName">Выберите файл...</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="caFileStatus" style="margin-top: 8px;"></div>
|
|
||||||
<small style="color: #95a5a6; display: block; margin-top: 5px;">
|
|
||||||
<i class="fas fa-info-circle"></i> CA Bundle опционален, но рекомендуется для полной цепочки сертификации
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section" id="sectionAddProxy" style="display: none;">
|
|
||||||
<div class="vaccess-page">
|
|
||||||
<!-- Хлебные крошки -->
|
|
||||||
<div class="breadcrumbs">
|
|
||||||
<div class="breadcrumbs-left">
|
|
||||||
<button class="breadcrumb-item" onclick="backToMainFromAddProxy()">
|
|
||||||
<i class="fas fa-arrow-left"></i> Назад
|
|
||||||
</button>
|
|
||||||
<span class="breadcrumb-separator">/</span>
|
|
||||||
<span class="breadcrumb-item active">Добавить прокси сервис</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Заголовок -->
|
|
||||||
<div class="vaccess-header">
|
|
||||||
<div class="vaccess-title-block">
|
|
||||||
<h2 class="vaccess-title">
|
|
||||||
<i class="fas fa-plus-circle"></i>
|
|
||||||
<span>Создание прокси сервиса</span>
|
|
||||||
</h2>
|
|
||||||
<p class="vaccess-subtitle">Настройте проксирование внешнего домена на локальный сервис</p>
|
|
||||||
</div>
|
|
||||||
<div class="vaccess-actions">
|
|
||||||
<button class="action-btn save-btn" id="createProxyBtn">
|
|
||||||
<i class="fas fa-check"></i>
|
|
||||||
<span>Создать прокси</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Форма создания прокси -->
|
|
||||||
<div class="vaccess-tab-content">
|
|
||||||
<div class="site-creator-form">
|
|
||||||
|
|
||||||
<!-- Единая форма -->
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="settings-form">
|
|
||||||
|
|
||||||
<!-- Основная информация -->
|
|
||||||
<h3 class="form-subsection-title"><i class="fas fa-info-circle"></i> Основная информация</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Внешний домен: <span style="color: #e74c3c;">*</span></label>
|
|
||||||
<input type="text" class="form-input" id="newProxyDomain" placeholder="example.com">
|
|
||||||
<small style="color: #95a5a6; display: block; margin-top: 5px;">
|
|
||||||
<i class="fas fa-info-circle"></i> Домен, на который будут приходить запросы (например: git.example.ru)
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Локальный адрес: <span style="color: #e74c3c;">*</span></label>
|
|
||||||
<input type="text" class="form-input" id="newProxyLocalAddr" placeholder="127.0.0.1" value="127.0.0.1">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Локальный порт: <span style="color: #e74c3c;">*</span></label>
|
|
||||||
<input type="text" class="form-input" id="newProxyLocalPort" placeholder="3000">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Статус:</label>
|
|
||||||
<select class="form-input" id="newProxyStatus">
|
|
||||||
<option value="enable">Включён</option>
|
|
||||||
<option value="disable">Отключён</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">HTTPS к сервису:</label>
|
|
||||||
<div class="toggle-wrapper">
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" id="newProxyServiceHTTPS">
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="toggle-label">Включён</span>
|
|
||||||
</div>
|
|
||||||
<small style="color: #95a5a6; display: block; margin-top: 5px;">
|
|
||||||
<i class="fas fa-info-circle"></i> Использовать HTTPS при подключении к локальному сервису
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Авто HTTPS:</label>
|
|
||||||
<div class="toggle-wrapper">
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" id="newProxyAutoHTTPS" checked>
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="toggle-label">Включён</span>
|
|
||||||
</div>
|
|
||||||
<small style="color: #95a5a6; display: block; margin-top: 5px;">
|
|
||||||
<i class="fas fa-info-circle"></i> Автоматически перенаправлять HTTP запросы на HTTPS
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SSL Сертификаты -->
|
|
||||||
<h3 class="form-subsection-title"><i class="fas fa-lock"></i> SSL Сертификаты (опционально)</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Режим сертификата:</label>
|
|
||||||
<select class="form-input" id="proxyCertMode" onchange="toggleProxyCertUpload()">
|
|
||||||
<option value="none">Без сертификата (fallback)</option>
|
|
||||||
<option value="auto">Автоматическое создание сертификата</option>
|
|
||||||
<option value="upload">Загрузить файлы сертификата</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="proxyCertUploadBlock" style="display: none;">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Certificate (*.crt): <span style="color: #e74c3c;">*</span></label>
|
|
||||||
<div class="file-upload-wrapper">
|
|
||||||
<input type="file" class="file-input" id="proxyCertFile" accept=".crt,.pem" onchange="handleProxyCertFileSelect(this, 'certificate')">
|
|
||||||
<label for="proxyCertFile" class="file-upload-btn">
|
|
||||||
<i class="fas fa-file-upload"></i>
|
|
||||||
<span id="proxyCertFileName">Выберите файл...</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="proxyCertFileStatus" style="margin-top: 8px;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Private Key (*.key): <span style="color: #e74c3c;">*</span></label>
|
|
||||||
<div class="file-upload-wrapper">
|
|
||||||
<input type="file" class="file-input" id="proxyKeyFile" accept=".key,.pem" onchange="handleProxyCertFileSelect(this, 'privatekey')">
|
|
||||||
<label for="proxyKeyFile" class="file-upload-btn">
|
|
||||||
<i class="fas fa-file-upload"></i>
|
|
||||||
<span id="proxyKeyFileName">Выберите файл...</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="proxyKeyFileStatus" style="margin-top: 8px;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">CA Bundle (*.crt):</label>
|
|
||||||
<div class="file-upload-wrapper">
|
|
||||||
<input type="file" class="file-input" id="proxyCaFile" accept=".crt,.pem" onchange="handleProxyCertFileSelect(this, 'cabundle')">
|
|
||||||
<label for="proxyCaFile" class="file-upload-btn">
|
|
||||||
<i class="fas fa-file-upload"></i>
|
|
||||||
<span id="proxyCaFileName">Выберите файл...</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="proxyCaFileStatus" style="margin-top: 8px;"></div>
|
|
||||||
<small style="color: #95a5a6; display: block; margin-top: 5px;">
|
|
||||||
<i class="fas fa-info-circle"></i> CA Bundle опционален, но рекомендуется для полной цепочки сертификации
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section" id="sectionCertManager" style="display: none;">
|
|
||||||
<div class="vaccess-page">
|
|
||||||
<!-- Хлебные крошки -->
|
|
||||||
<div class="breadcrumbs">
|
|
||||||
<div class="breadcrumbs-left">
|
|
||||||
<button class="breadcrumb-item" onclick="backFromCertManager()">
|
|
||||||
<i class="fas fa-arrow-left"></i> Назад
|
|
||||||
</button>
|
|
||||||
<span class="breadcrumb-separator">/</span>
|
|
||||||
<span class="breadcrumb-item active" id="certManagerBreadcrumb">Сертификаты</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Заголовок -->
|
|
||||||
<div class="vaccess-header">
|
|
||||||
<div class="vaccess-title-block">
|
|
||||||
<h2 class="vaccess-title" id="certManagerTitle">
|
|
||||||
<i class="fas fa-shield-alt"></i>
|
|
||||||
<span>Управление сертификатами</span>
|
|
||||||
</h2>
|
|
||||||
<p class="vaccess-subtitle" id="certManagerSubtitle">Просмотр и управление SSL сертификатами для домена</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Контент -->
|
|
||||||
<div class="vaccess-tab-content">
|
|
||||||
<div class="cert-manager-content" id="certManagerContent">
|
|
||||||
<!-- Сертификаты будут загружены сюда -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="footer">
|
|
||||||
<p>vServer Admin Panel © 2025 | Автор: Суманеев Роман</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="notification" class="notification"></div>
|
|
||||||
|
|
||||||
<div id="appLoader" class="app-loader">
|
|
||||||
<div class="loader-content">
|
|
||||||
<div class="loader-icon">🚀</div>
|
|
||||||
<div class="loader-text">Запуск vServer...</div>
|
|
||||||
<div class="loader-spinner"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="modalOverlay" class="modal-overlay">
|
|
||||||
<div class="modal-window">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3 class="modal-title" id="modalTitle">Редактирование</h3>
|
|
||||||
<button class="modal-close-btn" id="modalCloseBtn">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-content" id="modalContent"></div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="action-btn" id="modalCancelBtn">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
<span>Отмена</span>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn save-btn" id="modalSaveBtn">
|
|
||||||
<i class="fas fa-save"></i>
|
|
||||||
<span>Сохранить</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="fieldEditorOverlay" class="modal-overlay">
|
|
||||||
<div class="modal-window" style="max-width: 600px;">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3 class="modal-title" id="fieldEditorTitle">Редактирование поля</h3>
|
|
||||||
<button class="modal-close-btn" onclick="closeFieldEditor()">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-content" id="fieldEditorContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="templates-container"></div>
|
|
||||||
|
|
||||||
<script src="wailsjs/runtime/runtime.js"></script>
|
|
||||||
<script src="wailsjs/go/admin/App.js"></script>
|
|
||||||
<script type="module" src="assets/js/main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<!-- Шаблон редактирования сайта -->
|
|
||||||
<template id="edit-site-template">
|
|
||||||
<div class="settings-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Статус сайта:</label>
|
|
||||||
<div class="status-toggle">
|
|
||||||
<button class="status-btn" data-status="active" data-value="active">
|
|
||||||
<i class="fas fa-check-circle"></i> Active
|
|
||||||
</button>
|
|
||||||
<button class="status-btn" data-status="inactive" data-value="inactive">
|
|
||||||
<i class="fas fa-times-circle"></i> Inactive
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Название сайта:</label>
|
|
||||||
<input type="text" class="form-input" id="editName">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Host:</label>
|
|
||||||
<input type="text" class="form-input" id="editHost">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Alias:</label>
|
|
||||||
<div class="tag-input-wrapper">
|
|
||||||
<input type="text" class="form-input" id="editAliasInput" placeholder="Введите alias и нажмите Добавить...">
|
|
||||||
<button class="action-btn" onclick="addAliasTag()"><i class="fas fa-plus"></i> Добавить</button>
|
|
||||||
</div>
|
|
||||||
<div class="tags-container" id="aliasTagsContainer"></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Root файл:</label>
|
|
||||||
<input type="text" class="form-input" id="editRootFile">
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Роутинг:</label>
|
|
||||||
<div class="toggle-wrapper">
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" id="editRouting">
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="toggle-label">Включён</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Авто SSL:</label>
|
|
||||||
<div class="toggle-wrapper">
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" id="editAutoCreateSSL">
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="toggle-label">Включён</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Шаблон редактирования прокси -->
|
|
||||||
<template id="edit-proxy-template">
|
|
||||||
<div class="settings-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Статус прокси:</label>
|
|
||||||
<div class="status-toggle">
|
|
||||||
<button class="status-btn" data-status="enable" data-value="enable">
|
|
||||||
<i class="fas fa-check-circle"></i> Включён
|
|
||||||
</button>
|
|
||||||
<button class="status-btn" data-status="disable" data-value="disable">
|
|
||||||
<i class="fas fa-times-circle"></i> Отключён
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Внешний домен:</label>
|
|
||||||
<input type="text" class="form-input" id="editDomain">
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Локальный адрес:</label>
|
|
||||||
<input type="text" class="form-input" id="editLocalAddr">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Локальный порт:</label>
|
|
||||||
<input type="text" class="form-input" id="editLocalPort">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row form-row-3">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">HTTPS к сервису:</label>
|
|
||||||
<div class="toggle-wrapper">
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" id="editServiceHTTPS">
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="toggle-label">Включён</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Авто HTTPS:</label>
|
|
||||||
<div class="toggle-wrapper">
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" id="editAutoHTTPS">
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="toggle-label">Включён</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Авто SSL:</label>
|
|
||||||
<div class="toggle-wrapper">
|
|
||||||
<label class="toggle-switch">
|
|
||||||
<input type="checkbox" id="editProxyAutoCreateSSL">
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
<span class="toggle-label">Включён</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
@@ -2,10 +2,18 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
@@ -32,10 +40,103 @@ func NewApp() *App {
|
|||||||
|
|
||||||
var isSingleInstance bool = false
|
var isSingleInstance bool = false
|
||||||
|
|
||||||
|
func initDirectories() {
|
||||||
|
dirs := []string{
|
||||||
|
"WebServer",
|
||||||
|
"WebServer/www",
|
||||||
|
"WebServer/cert",
|
||||||
|
"WebServer/cert/no_cert",
|
||||||
|
"WebServer/tools",
|
||||||
|
"WebServer/tools/logs",
|
||||||
|
"WebServer/tools/error_page",
|
||||||
|
"WebServer/tools/Proxy_vAccess",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range dirs {
|
||||||
|
os.MkdirAll(dir, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дефолтный config.json
|
||||||
|
if _, err := os.Stat(config.ConfigPath); os.IsNotExist(err) {
|
||||||
|
defaultConfig := map[string]interface{}{
|
||||||
|
"Site_www": []interface{}{},
|
||||||
|
"Proxy_Service": []interface{}{},
|
||||||
|
"Soft_Settings": map[string]interface{}{
|
||||||
|
"php_host": "localhost",
|
||||||
|
"php_port": 8000,
|
||||||
|
"mysql_host": "127.0.0.1",
|
||||||
|
"mysql_port": 3306,
|
||||||
|
"proxy_enabled": false,
|
||||||
|
"ACME_enabled": false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, _ := json.MarshalIndent(defaultConfig, "", " ")
|
||||||
|
os.WriteFile(config.ConfigPath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Страница ошибки
|
||||||
|
errorPage := "WebServer/tools/error_page/index.html"
|
||||||
|
if _, err := os.Stat(errorPage); os.IsNotExist(err) {
|
||||||
|
os.WriteFile(errorPage, []byte("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Error</title></head><body><h1>Error</h1></body></html>"), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback SSL-сертификат (самоподписанный)
|
||||||
|
certFile := filepath.Join("WebServer", "cert", "no_cert", "certificate.crt")
|
||||||
|
keyFile := filepath.Join("WebServer", "cert", "no_cert", "private.key")
|
||||||
|
if _, err := os.Stat(certFile); os.IsNotExist(err) {
|
||||||
|
generateSelfSignedCert(certFile, keyFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSelfSignedCert(certPath, keyPath string) {
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
template := x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "localhost"},
|
||||||
|
NotBefore: time.Now(),
|
||||||
|
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
IsCA: true,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
certOut, err := os.Create(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||||
|
certOut.Close()
|
||||||
|
|
||||||
|
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keyOut, err := os.Create(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||||
|
keyOut.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) Startup(ctx context.Context) {
|
func (a *App) Startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
appContext = ctx
|
appContext = ctx
|
||||||
|
|
||||||
|
// Создаём структуру папок при первом запуске
|
||||||
|
initDirectories()
|
||||||
|
|
||||||
// Проверяем, не запущен ли уже vServer
|
// Проверяем, не запущен ли уже vServer
|
||||||
if !tools.CheckSingleInstance() {
|
if !tools.CheckSingleInstance() {
|
||||||
runtime.EventsEmit(ctx, "server:already_running", true)
|
runtime.EventsEmit(ctx, "server:already_running", true)
|
||||||
@@ -125,7 +226,11 @@ func (a *App) Shutdown(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) monitorServices() {
|
func (a *App) monitorServices() {
|
||||||
time.Sleep(1 * time.Second) // Ждём секунду перед первой проверкой
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
// Первое событие сразу
|
||||||
|
status := services.GetAllServicesStatus()
|
||||||
|
runtime.EventsEmit(appContext, "service:changed", status)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
@@ -318,19 +423,12 @@ func (a *App) DisableACMEService() string {
|
|||||||
func (a *App) OpenSiteFolder(host string) string {
|
func (a *App) OpenSiteFolder(host string) string {
|
||||||
folderPath := "WebServer/www/" + host
|
folderPath := "WebServer/www/" + host
|
||||||
|
|
||||||
// Получаем абсолютный путь
|
|
||||||
absPath, err := tools.AbsPath(folderPath)
|
absPath, err := tools.AbsPath(folderPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "Error: " + err.Error()
|
return "Error: " + err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Открываем папку в проводнике
|
exec.Command("explorer", absPath).Start()
|
||||||
cmd := exec.Command("explorer", absPath)
|
|
||||||
err = cmd.Start()
|
|
||||||
if err != nil {
|
|
||||||
return "Error: " + err.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Folder opened"
|
return "Folder opened"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func GetProxyList() []ProxyInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
proxyInfo := ProxyInfo{
|
proxyInfo := ProxyInfo{
|
||||||
|
Name: proxyConfig.Name,
|
||||||
Enable: proxyConfig.Enable,
|
Enable: proxyConfig.Enable,
|
||||||
ExternalDomain: proxyConfig.ExternalDomain,
|
ExternalDomain: proxyConfig.ExternalDomain,
|
||||||
LocalAddress: proxyConfig.LocalAddress,
|
LocalAddress: proxyConfig.LocalAddress,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
type ProxyInfo struct {
|
type ProxyInfo struct {
|
||||||
Enable bool `json:"enable"`
|
Name string `json:"Name"`
|
||||||
ExternalDomain string `json:"external_domain"`
|
Enable bool `json:"Enable"`
|
||||||
LocalAddress string `json:"local_address"`
|
ExternalDomain string `json:"ExternalDomain"`
|
||||||
LocalPort string `json:"local_port"`
|
LocalAddress string `json:"LocalAddress"`
|
||||||
ServiceHTTPSuse bool `json:"service_https_use"`
|
LocalPort string `json:"LocalPort"`
|
||||||
AutoHTTPS bool `json:"auto_https"`
|
ServiceHTTPSuse bool `json:"ServiceHTTPSuse"`
|
||||||
AutoCreateSSL bool `json:"auto_create_ssl"`
|
AutoHTTPS bool `json:"AutoHTTPS"`
|
||||||
Status string `json:"status"`
|
AutoCreateSSL bool `json:"AutoCreateSSL"`
|
||||||
|
Status string `json:"Status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func getProxyStatus() ServiceStatus {
|
|||||||
return ServiceStatus{
|
return ServiceStatus{
|
||||||
Name: "Proxy",
|
Name: "Proxy",
|
||||||
Status: status,
|
Status: status,
|
||||||
Port: "-",
|
Port: "",
|
||||||
Info: info,
|
Info: info,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type Soft_Settings struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Proxy_Service struct {
|
type Proxy_Service struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
Enable bool `json:"Enable"`
|
Enable bool `json:"Enable"`
|
||||||
ExternalDomain string `json:"ExternalDomain"`
|
ExternalDomain string `json:"ExternalDomain"`
|
||||||
LocalAddress string `json:"LocalAddress"`
|
LocalAddress string `json:"LocalAddress"`
|
||||||
|
|||||||
126
build_admin.ps1
126
build_admin.ps1
@@ -1,8 +1,7 @@
|
|||||||
# vServer Admin Panel Builder
|
# vServer Admin Panel Builder
|
||||||
$ErrorActionPreference = 'SilentlyContinue'
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
# Очищаем консоль
|
|
||||||
Clear-Host
|
Clear-Host
|
||||||
Start-Sleep -Milliseconds 100
|
Start-Sleep -Milliseconds 100
|
||||||
|
|
||||||
@@ -38,78 +37,127 @@ function Write-ProgressBar {
|
|||||||
Write-Host " [$bar] $Percent%" -ForegroundColor Cyan
|
Write-Host " [$bar] $Percent%" -ForegroundColor Cyan
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Check-And-Install {
|
||||||
|
param($Name, $Command, $WingetId)
|
||||||
|
|
||||||
|
$found = Get-Command $Command -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if (-not $found) {
|
||||||
|
Write-Err "$Name not found!"
|
||||||
|
$answer = Read-Host " ? Install $Name via winget? (y/n)"
|
||||||
|
if ($answer -eq 'y' -or $answer -eq 'Y') {
|
||||||
|
Write-Info "Installing $Name..."
|
||||||
|
winget install --id $WingetId --accept-source-agreements --accept-package-agreements 2>&1 | Out-Null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Success "$Name installed! Restart terminal and run script again."
|
||||||
|
} else {
|
||||||
|
Write-Err "Failed to install. Install manually: winget install $WingetId"
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
exit 1
|
||||||
|
} else {
|
||||||
|
Write-Err "Cannot continue without $Name"
|
||||||
|
Write-Host ""
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Success "$Name found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "=================================================" -ForegroundColor Magenta
|
Write-Host "=================================================" -ForegroundColor Magenta
|
||||||
Write-Host " vServer Admin Panel Builder" -ForegroundColor Cyan
|
Write-Host " vServer Admin Panel Builder" -ForegroundColor Cyan
|
||||||
Write-Host "=================================================" -ForegroundColor Magenta
|
Write-Host "=================================================" -ForegroundColor Magenta
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
Write-Step 1 4 "Проверка go.mod..."
|
# Step 0: Check dependencies
|
||||||
|
Write-Step 1 6 "Checking dependencies..."
|
||||||
|
Check-And-Install "Go" "go" "GoLang.Go"
|
||||||
|
Check-And-Install "Node.js" "node" "OpenJS.NodeJS.LTS"
|
||||||
|
Check-And-Install "npm" "npm" "OpenJS.NodeJS.LTS"
|
||||||
|
Write-ProgressBar 10
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Step 1: go.mod
|
||||||
|
Write-Step 2 6 "Checking go.mod..."
|
||||||
if (-not (Test-Path "go.mod")) {
|
if (-not (Test-Path "go.mod")) {
|
||||||
Write-Info "Создание go.mod..."
|
Write-Info "Creating go.mod..."
|
||||||
go mod init vServer 2>&1 | Out-Null
|
go mod init vServer 2>&1 | Out-Null
|
||||||
Write-Success "Создан"
|
Write-Success "Created"
|
||||||
} else {
|
} else {
|
||||||
Write-Success "Найден"
|
Write-Success "Found"
|
||||||
}
|
}
|
||||||
Write-ProgressBar 25
|
Write-ProgressBar 20
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
Write-Step 2 4 "Установка зависимостей..."
|
# Step 2: Go dependencies
|
||||||
|
Write-Step 3 6 "Installing Go dependencies..."
|
||||||
go mod tidy 2>&1 | Out-Null
|
go mod tidy 2>&1 | Out-Null
|
||||||
Write-Success "Зависимости установлены"
|
Write-Success "Dependencies installed"
|
||||||
Write-ProgressBar 50
|
Write-ProgressBar 40
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
Write-Step 3 5 "Проверка Wails CLI..."
|
# Step 3: Wails CLI
|
||||||
|
Write-Step 4 6 "Checking Wails CLI..."
|
||||||
$null = wails version 2>&1
|
$null = wails version 2>&1
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Info "Установка Wails CLI..."
|
Write-Info "Installing Wails CLI..."
|
||||||
go install github.com/wailsapp/wails/v2/cmd/wails@latest 2>&1 | Out-Null
|
go install github.com/wailsapp/wails/v2/cmd/wails@latest 2>&1 | Out-Null
|
||||||
Write-Success "Установлен"
|
Write-Success "Installed"
|
||||||
} else {
|
} else {
|
||||||
Write-Success "Найден"
|
Write-Success "Found"
|
||||||
}
|
}
|
||||||
Write-ProgressBar 60
|
Write-ProgressBar 55
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
Write-Step 4 5 "Генерация биндингов..."
|
# Step 4: Vue frontend
|
||||||
Write-Info "Создание TypeScript/JS биндингов для Go методов..."
|
Write-Step 5 6 "Building Vue frontend..."
|
||||||
wails generate module 2>&1 | Out-Null
|
Push-Location "front_vue"
|
||||||
Write-Success "Биндинги сгенерированы"
|
Write-Info "npm install..."
|
||||||
Write-ProgressBar 80
|
npm install 2>&1 | Out-Null
|
||||||
|
Write-Info "npm run build..."
|
||||||
|
npm run build 2>&1 | Out-Null
|
||||||
|
Pop-Location
|
||||||
|
Write-Success "Frontend built"
|
||||||
|
Write-ProgressBar 75
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
Write-Step 5 5 "Сборка приложения..."
|
# Step 5: Wails build
|
||||||
Write-Info "Компиляция (может занять ~10 сек)..."
|
Write-Step 6 6 "Building application..."
|
||||||
|
Write-Info "Compiling (may take ~30 sec)..."
|
||||||
|
wails build 2>&1 | Out-Null
|
||||||
|
|
||||||
wails build -f admin.go 2>&1 | Out-Null
|
$exePath = $null
|
||||||
|
if (Test-Path "build\bin\vServer-Admin.exe") {
|
||||||
|
$exePath = "build\bin\vServer-Admin.exe"
|
||||||
|
}
|
||||||
|
if ((-not $exePath) -and (Test-Path "bin\vServer-Admin.exe")) {
|
||||||
|
$exePath = "bin\vServer-Admin.exe"
|
||||||
|
}
|
||||||
|
|
||||||
if (Test-Path "bin\vServer-Admin.exe") {
|
if ($exePath) {
|
||||||
Write-Success "Скомпилировано"
|
Write-Success "Compiled"
|
||||||
Write-ProgressBar 100
|
Write-ProgressBar 100
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
Write-Host "Finalizing..." -ForegroundColor Cyan
|
||||||
Write-Host "Финализация..." -ForegroundColor Cyan
|
Move-Item -Path $exePath -Destination "vSerf.exe" -Force -ErrorAction SilentlyContinue
|
||||||
Move-Item -Path "bin\vServer-Admin.exe" -Destination "vSerf.exe" -Force 2>$null
|
Write-Success "File moved: vSerf.exe"
|
||||||
Write-Success "Файл перемещён: vSerf.exe"
|
if (Test-Path "build") { Remove-Item -Path "build" -Recurse -Force -ErrorAction SilentlyContinue }
|
||||||
|
if (Test-Path "bin") { Remove-Item -Path "bin" -Recurse -Force -ErrorAction SilentlyContinue }
|
||||||
if (Test-Path "bin") { Remove-Item -Path "bin" -Recurse -Force 2>$null }
|
if (Test-Path "windows") { Remove-Item -Path "windows" -Recurse -Force -ErrorAction SilentlyContinue }
|
||||||
if (Test-Path "windows") { Remove-Item -Path "windows" -Recurse -Force 2>$null }
|
Write-Success "Temp files removed"
|
||||||
Write-Success "Временные файлы удалены"
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
Write-Host "=================================================" -ForegroundColor Green
|
Write-Host "=================================================" -ForegroundColor Green
|
||||||
Write-Host " УСПЕШНО СОБРАНО!" -ForegroundColor Green
|
Write-Host " BUILD SUCCESS!" -ForegroundColor Green
|
||||||
Write-Host " Файл: " -ForegroundColor Green -NoNewline
|
Write-Host " File: " -ForegroundColor Green -NoNewline
|
||||||
Write-Host "vSerf.exe" -ForegroundColor Cyan
|
Write-Host "vSerf.exe" -ForegroundColor Cyan
|
||||||
Write-Host "=================================================" -ForegroundColor Green
|
Write-Host "=================================================" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Err "Ошибка компиляции"
|
Write-Err "Compilation error"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
Write-Host "=================================================" -ForegroundColor Red
|
Write-Host "=================================================" -ForegroundColor Red
|
||||||
Write-Host " ОШИБКА СБОРКИ!" -ForegroundColor Red
|
Write-Host " BUILD FAILED!" -ForegroundColor Red
|
||||||
Write-Host "=================================================" -ForegroundColor Red
|
Write-Host "=================================================" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
front_vue/package.json.md5
Normal file
1
front_vue/package.json.md5
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ed2519d496d19e187b316251711a6b7a
|
||||||
@@ -1,7 +1,97 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import MainLayout from '@design/layouts/MainLayout.vue'
|
import MainLayout from '@design/layouts/MainLayout.vue'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const servicesStore = useServicesStore()
|
||||||
|
|
||||||
|
const unwatch = watch(
|
||||||
|
() => servicesStore.list,
|
||||||
|
(list) => {
|
||||||
|
const hasRunning = list.some(s => s.status === true)
|
||||||
|
if (hasRunning) {
|
||||||
|
setTimeout(() => { loading.value = false }, 300)
|
||||||
|
unwatch()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => { loading.value = false }, 15000)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<transition name="loader-fade">
|
||||||
|
<div v-if="loading" class="app-loader">
|
||||||
|
<div class="loader-content">
|
||||||
|
<div class="loader-icon">🚀</div>
|
||||||
|
<div class="loader-text">Запуск vServer...</div>
|
||||||
|
<div class="loader-spinner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
<MainLayout />
|
<MainLayout />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app-loader {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary, #0f1729) 50%, var(--bg-tertiary, #1a1040) 100%);
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
animation: rocket-bounce 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-text {
|
||||||
|
font-size: var(--text-xl, 20px);
|
||||||
|
font-weight: var(--font-semibold, 600);
|
||||||
|
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid rgba(139, 92, 246, 0.2);
|
||||||
|
border-top-color: var(--accent-purple, #7c3aed);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rocket-bounce {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-20px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-fade-leave-active {
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
"Name": "Git Server",
|
||||||
"Enable": true,
|
"Enable": true,
|
||||||
"ExternalDomain": "git.example.ru",
|
"ExternalDomain": "git.example.ru",
|
||||||
"LocalAddress": "127.0.0.1",
|
"LocalAddress": "127.0.0.1",
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
"AutoCreateSSL": false
|
"AutoCreateSSL": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"Name": "API Backend",
|
||||||
"Enable": true,
|
"Enable": true,
|
||||||
"ExternalDomain": "api.example.com",
|
"ExternalDomain": "api.example.com",
|
||||||
"LocalAddress": "127.0.0.1",
|
"LocalAddress": "127.0.0.1",
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
"AutoCreateSSL": true
|
"AutoCreateSSL": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"Name": "Test App",
|
||||||
"Enable": false,
|
"Enable": false,
|
||||||
"ExternalDomain": "test.example.net",
|
"ExternalDomain": "test.example.net",
|
||||||
"LocalAddress": "127.0.0.1",
|
"LocalAddress": "127.0.0.1",
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export const mockApi = {
|
|||||||
return 'OK'
|
return 'OK'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateSiteCache() {
|
||||||
|
await delay(200)
|
||||||
|
return 'OK'
|
||||||
|
},
|
||||||
|
|
||||||
async openSiteFolder(host) {
|
async openSiteFolder(host) {
|
||||||
await delay(100)
|
await delay(100)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export const wailsApi = {
|
|||||||
return await app().DeleteSite(host)
|
return await app().DeleteSite(host)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateSiteCache() {
|
||||||
|
return await app().UpdateSiteCache()
|
||||||
|
},
|
||||||
|
|
||||||
async openSiteFolder(host) {
|
async openSiteFolder(host) {
|
||||||
return await app().OpenSiteFolder(host)
|
return await app().OpenSiteFolder(host)
|
||||||
},
|
},
|
||||||
|
|||||||
58
front_vue/src/Core/composables/useDraggable.js
Normal file
58
front_vue/src/Core/composables/useDraggable.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
export function useDraggable(list) {
|
||||||
|
const dragIndex = ref(null)
|
||||||
|
const dragOverIndex = ref(null)
|
||||||
|
|
||||||
|
const onDragStart = (index, event) => {
|
||||||
|
dragIndex.value = index
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
event.target.closest('tr, .draggable-item')?.classList.add('dragging')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragOver = (index, event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
dragOverIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragEnter = (index, event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
dragOverIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragLeave = () => {
|
||||||
|
dragOverIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (index) => {
|
||||||
|
if (dragIndex.value === null || dragIndex.value === index) {
|
||||||
|
dragIndex.value = null
|
||||||
|
dragOverIndex.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...list.value]
|
||||||
|
const [moved] = items.splice(dragIndex.value, 1)
|
||||||
|
items.splice(index, 0, moved)
|
||||||
|
list.value = items
|
||||||
|
|
||||||
|
dragIndex.value = null
|
||||||
|
dragOverIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragEnd = (event) => {
|
||||||
|
event.target.closest('tr, .draggable-item')?.classList.remove('dragging')
|
||||||
|
dragIndex.value = null
|
||||||
|
dragOverIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dragIndex,
|
||||||
|
dragOverIndex,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDragEnter,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
onDragEnd,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"title": "vServer Admin Panel",
|
"title": "vServer Admin Panel",
|
||||||
"logo": "vServer",
|
"logo": "vServer",
|
||||||
"footer": "vServer Admin Panel © 2025 | Author: Sumaneev Roman",
|
"footerAuthor": "Author: Sumaneev Roman",
|
||||||
"loading": "Starting vServer..."
|
"loading": "Starting vServer..."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -18,7 +18,10 @@
|
|||||||
"running": "Server is running",
|
"running": "Server is running",
|
||||||
"stopped": "Server is stopped",
|
"stopped": "Server is stopped",
|
||||||
"start": "Start",
|
"start": "Start",
|
||||||
"stop": "Stop"
|
"stop": "Stop",
|
||||||
|
"starting": "Starting...",
|
||||||
|
"stopping": "Stopping...",
|
||||||
|
"wait": "Please wait..."
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"title": "Services Status",
|
"title": "Services Status",
|
||||||
@@ -174,10 +177,11 @@
|
|||||||
},
|
},
|
||||||
"notify": {
|
"notify": {
|
||||||
"settingsSaved": "Settings saved and services restarted!",
|
"settingsSaved": "Settings saved and services restarted!",
|
||||||
"settingsTestMode": "Settings saved (test mode)",
|
"settingsSaved": "Settings saved",
|
||||||
"dataSaved": "Data saved (test mode)",
|
"dataSaved": "Data saved",
|
||||||
"siteCreated": "Site created successfully!",
|
"siteCreated": "Site created successfully!",
|
||||||
"siteDeleted": "Site deleted successfully!",
|
"siteDeleted": "Site deleted successfully!",
|
||||||
|
"proxyCreated": "Proxy created successfully!",
|
||||||
"proxyDeleted": "Proxy deleted successfully!",
|
"proxyDeleted": "Proxy deleted successfully!",
|
||||||
"changesSaved": "Changes saved and applied!",
|
"changesSaved": "Changes saved and applied!",
|
||||||
"serversRestarted": "Servers restarted!",
|
"serversRestarted": "Servers restarted!",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"title": "vServer Admin Panel",
|
"title": "vServer Admin Panel",
|
||||||
"logo": "vServer",
|
"logo": "vServer",
|
||||||
"footer": "vServer Admin Panel © 2025 | Автор: Суманеев Роман",
|
"footerAuthor": "Автор: Суманеев Роман",
|
||||||
"loading": "Запуск vServer..."
|
"loading": "Запуск vServer..."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -18,7 +18,10 @@
|
|||||||
"running": "Сервер запущен",
|
"running": "Сервер запущен",
|
||||||
"stopped": "Сервер остановлен",
|
"stopped": "Сервер остановлен",
|
||||||
"start": "Запустить",
|
"start": "Запустить",
|
||||||
"stop": "Остановить"
|
"stop": "Остановить",
|
||||||
|
"starting": "Запускается...",
|
||||||
|
"stopping": "Выключается...",
|
||||||
|
"wait": "Ожидайте..."
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"title": "Статус сервисов",
|
"title": "Статус сервисов",
|
||||||
@@ -174,10 +177,11 @@
|
|||||||
},
|
},
|
||||||
"notify": {
|
"notify": {
|
||||||
"settingsSaved": "Настройки сохранены и сервисы перезапущены!",
|
"settingsSaved": "Настройки сохранены и сервисы перезапущены!",
|
||||||
"settingsTestMode": "Настройки сохранены (тестовый режим)",
|
"settingsSaved": "Настройки сохранены",
|
||||||
"dataSaved": "Данные сохранены (тестовый режим)",
|
"dataSaved": "Данные сохранены",
|
||||||
"siteCreated": "Сайт успешно создан!",
|
"siteCreated": "Сайт успешно создан!",
|
||||||
"siteDeleted": "Сайт успешно удалён!",
|
"siteDeleted": "Сайт успешно удалён!",
|
||||||
|
"proxyCreated": "Прокси успешно создан!",
|
||||||
"proxyDeleted": "Прокси успешно удалён!",
|
"proxyDeleted": "Прокси успешно удалён!",
|
||||||
"changesSaved": "Изменения сохранены и применены!",
|
"changesSaved": "Изменения сохранены и применены!",
|
||||||
"serversRestarted": "Серверы перезапущены!",
|
"serversRestarted": "Серверы перезапущены!",
|
||||||
|
|||||||
@@ -15,5 +15,38 @@ export const useProxiesStore = defineStore('proxies', {
|
|||||||
this.loaded = true
|
this.loaded = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async create(proxyData) {
|
||||||
|
const config = await api.getConfig()
|
||||||
|
config.Proxy_Service.push({
|
||||||
|
Name: proxyData.name || '',
|
||||||
|
Enable: proxyData.enabled,
|
||||||
|
ExternalDomain: proxyData.domain,
|
||||||
|
LocalAddress: proxyData.localAddr,
|
||||||
|
LocalPort: proxyData.localPort,
|
||||||
|
ServiceHTTPSuse: proxyData.serviceHttps,
|
||||||
|
AutoHTTPS: proxyData.autoHttps,
|
||||||
|
AutoCreateSSL: proxyData.autoSSL,
|
||||||
|
})
|
||||||
|
const result = await api.saveConfig(JSON.stringify(config))
|
||||||
|
if (result && !String(result).startsWith('Error')) {
|
||||||
|
await api.reloadConfig()
|
||||||
|
await this.load()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(domain) {
|
||||||
|
const config = await api.getConfig()
|
||||||
|
config.Proxy_Service = config.Proxy_Service.filter(
|
||||||
|
p => p.ExternalDomain !== domain
|
||||||
|
)
|
||||||
|
const result = await api.saveConfig(JSON.stringify(config))
|
||||||
|
if (result && !String(result).startsWith('Error')) {
|
||||||
|
await api.reloadConfig()
|
||||||
|
await this.load()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,17 +5,28 @@ export const useServicesStore = defineStore('services', {
|
|||||||
state: () => ({
|
state: () => ({
|
||||||
list: [],
|
list: [],
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
isOperating: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
async load() {
|
async load() {
|
||||||
const data = await api.getAllServicesStatus()
|
const data = await api.getAllServicesStatus()
|
||||||
if (data) {
|
if (data) {
|
||||||
this.list = Array.isArray(data) ? data : [data]
|
this.list = Array.isArray(data) ? data : Object.values(data)
|
||||||
this.loaded = true
|
this.loaded = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setPending(text) {
|
||||||
|
this.isOperating = true
|
||||||
|
this.list = this.list.map(s => ({ ...s, pending: text }))
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearPending() {
|
||||||
|
await this.load()
|
||||||
|
this.isOperating = false
|
||||||
|
},
|
||||||
|
|
||||||
async startService(name) {
|
async startService(name) {
|
||||||
const methods = {
|
const methods = {
|
||||||
HTTP: () => api.startHTTPService(),
|
HTTP: () => api.startHTTPService(),
|
||||||
|
|||||||
@@ -77,3 +77,12 @@ input, select, textarea, button {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-spin {
|
||||||
|
animation: icon-spin-anim 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes icon-spin-anim {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -166,3 +166,10 @@
|
|||||||
border-color: var(--btn-icon-hover-border);
|
border-color: var(--btn-icon-hover-border);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
|
||||||
|
const openSite = () => {
|
||||||
|
if (window.runtime?.BrowserOpenURL) {
|
||||||
|
window.runtime.BrowserOpenURL('https://vserf.ru')
|
||||||
|
} else {
|
||||||
|
window.open('https://vserf.ru', '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<p>{{ t('app.footer') }}</p>
|
<p>vServer Admin Panel © 2025-{{ currentYear }} | <a href="#" class="footer-link" @click.prevent="openSite">https://vserf.ru</a> | {{ t('app.footerAuthor') }}</p>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -13,8 +23,18 @@ const { t } = useI18n()
|
|||||||
padding: var(--space-sm) var(--space-lg);
|
padding: var(--space-sm) var(--space-lg);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-sm);
|
||||||
border-top: 1px solid var(--glass-border);
|
border-top: 1px solid var(--glass-border);
|
||||||
background: var(--glass-bg-dark);
|
background: var(--glass-bg-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { api } from '@core/api/index.js'
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const servicesStore = useServicesStore()
|
||||||
const { isDark, toggleTheme } = useTheme()
|
const { isDark, toggleTheme } = useTheme()
|
||||||
|
|
||||||
|
const isWails = typeof window !== 'undefined' && window?.runtime
|
||||||
|
const operating = ref(false)
|
||||||
|
const statusLabel = ref('')
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
const toggleLocale = () => {
|
const toggleLocale = () => {
|
||||||
const next = locale.value === 'ru' ? 'en' : 'ru'
|
const next = locale.value === 'ru' ? 'en' : 'ru'
|
||||||
locale.value = next
|
locale.value = next
|
||||||
@@ -10,12 +19,42 @@ const toggleLocale = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serverToggle = async () => {
|
const serverToggle = async () => {
|
||||||
|
if (operating.value) return
|
||||||
|
|
||||||
if (appStore.serverRunning) {
|
if (appStore.serverRunning) {
|
||||||
|
operating.value = true
|
||||||
|
statusLabel.value = t('server.stopping')
|
||||||
|
servicesStore.setPending(t('server.stopping'))
|
||||||
|
await api.stopServer()
|
||||||
|
await sleep(1500)
|
||||||
|
servicesStore.clearPending()
|
||||||
appStore.setServerRunning(false)
|
appStore.setServerRunning(false)
|
||||||
|
statusLabel.value = ''
|
||||||
|
operating.value = false
|
||||||
} else {
|
} else {
|
||||||
|
operating.value = true
|
||||||
|
statusLabel.value = t('server.starting')
|
||||||
|
servicesStore.setPending(t('server.starting'))
|
||||||
|
await api.startServer()
|
||||||
|
|
||||||
|
let attempts = 0
|
||||||
|
while (attempts < 20) {
|
||||||
|
await sleep(500)
|
||||||
|
const ready = await api.checkServicesReady()
|
||||||
|
if (ready) break
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
|
||||||
|
servicesStore.clearPending()
|
||||||
appStore.setServerRunning(true)
|
appStore.setServerRunning(true)
|
||||||
|
statusLabel.value = ''
|
||||||
|
operating.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const windowMinimise = () => { if (isWails) window.runtime.WindowMinimise() }
|
||||||
|
const windowMaximise = () => { if (isWails) window.runtime.WindowToggleMaximise() }
|
||||||
|
const windowClose = () => { if (isWails) window.runtime.Quit() }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -26,14 +65,14 @@ const serverToggle = async () => {
|
|||||||
<span class="logo-text">{{ t('app.logo') }}</span>
|
<span class="logo-text">{{ t('app.logo') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="server-status">
|
<div class="server-status">
|
||||||
<span class="status-indicator" :class="appStore.serverRunning ? 'status-online' : 'status-offline'"></span>
|
<span class="status-indicator" :class="[operating ? 'status-pending' : (appStore.serverRunning ? 'status-online' : 'status-offline')]"></span>
|
||||||
<span class="status-text">{{ appStore.serverRunning ? t('server.running') : t('server.stopped') }}</span>
|
<span class="status-text">{{ statusLabel || (appStore.serverRunning ? t('server.running') : t('server.stopped')) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="title-bar-right" style="--wails-draggable: no-drag">
|
<div class="title-bar-right" style="--wails-draggable: no-drag">
|
||||||
<button class="server-control-btn" @click="serverToggle">
|
<button class="server-control-btn" :disabled="operating" @click="serverToggle">
|
||||||
<i class="fas fa-power-off"></i>
|
<i class="fas fa-power-off"></i>
|
||||||
<span class="btn-text">{{ appStore.serverRunning ? t('server.stop') : t('server.start') }}</span>
|
<span class="btn-text">{{ operating ? t('server.wait') : (appStore.serverRunning ? t('server.stop') : t('server.start')) }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="locale-btn" @click="toggleLocale" :title="locale === 'ru' ? 'English' : 'Русский'">
|
<button class="locale-btn" @click="toggleLocale" :title="locale === 'ru' ? 'English' : 'Русский'">
|
||||||
{{ locale === 'ru' ? 'EN' : 'RU' }}
|
{{ locale === 'ru' ? 'EN' : 'RU' }}
|
||||||
@@ -41,13 +80,13 @@ const serverToggle = async () => {
|
|||||||
<button class="theme-btn" @click="toggleTheme" :title="isDark ? t('theme.light') : t('theme.dark')">
|
<button class="theme-btn" @click="toggleTheme" :title="isDark ? t('theme.light') : t('theme.dark')">
|
||||||
<i :class="isDark ? 'fas fa-sun' : 'fas fa-moon'"></i>
|
<i :class="isDark ? 'fas fa-sun' : 'fas fa-moon'"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="window-btn minimize-btn" :title="t('window.minimize')">
|
<button class="window-btn minimize-btn" :title="t('window.minimize')" @click="windowMinimise">
|
||||||
<i class="fas fa-window-minimize"></i>
|
<i class="fas fa-window-minimize"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="window-btn maximize-btn" :title="t('window.maximize')">
|
<button class="window-btn maximize-btn" :title="t('window.maximize')" @click="windowMaximise">
|
||||||
<i class="far fa-window-maximize"></i>
|
<i class="far fa-window-maximize"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="window-btn close-btn" :title="t('window.close')">
|
<button class="window-btn close-btn" :title="t('window.close')" @click="windowClose">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,6 +151,17 @@ const serverToggle = async () => {
|
|||||||
box-shadow: 0 0 8px var(--accent-red);
|
box-shadow: 0 0 8px var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
background: var(--accent-yellow, #f0ad4e);
|
||||||
|
box-shadow: 0 0 8px var(--accent-yellow, #f0ad4e);
|
||||||
|
animation: pulse-pending 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-pending {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
.status-text {
|
.status-text {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ const router = useRouter()
|
|||||||
const proxiesStore = useProxiesStore()
|
const proxiesStore = useProxiesStore()
|
||||||
const certsStore = useCertsStore()
|
const certsStore = useCertsStore()
|
||||||
|
|
||||||
|
const openUrl = (host) => {
|
||||||
|
if (window.runtime?.BrowserOpenURL) {
|
||||||
|
window.runtime.BrowserOpenURL('http://' + host)
|
||||||
|
} else {
|
||||||
|
window.open('http://' + host, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const findCertForDomain = (domain) => {
|
const findCertForDomain = (domain) => {
|
||||||
const direct = certsStore.list.find(c => c.domain === domain && c.has_cert)
|
const direct = certsStore.list.find(c => c.domain === domain && c.has_cert)
|
||||||
if (direct) return direct
|
if (direct) return direct
|
||||||
@@ -43,10 +51,10 @@ const findCertForDomain = (domain) => {
|
|||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>{{ t('sites.name') }}</th>
|
||||||
<th>{{ t('proxies.externalDomain') }}</th>
|
<th>{{ t('proxies.externalDomain') }}</th>
|
||||||
<th>{{ t('proxies.localAddress') }}</th>
|
<th>{{ t('proxies.localAddress') }}</th>
|
||||||
<th>{{ t('proxies.httpsCol') }}</th>
|
<th>HTTPS</th>
|
||||||
<th>{{ t('proxies.autoHttps') }}</th>
|
|
||||||
<th>{{ t('proxies.status') }}</th>
|
<th>{{ t('proxies.status') }}</th>
|
||||||
<th>{{ t('proxies.actions') }}</th>
|
<th>{{ t('proxies.actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -54,10 +62,13 @@ const findCertForDomain = (domain) => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="proxy in proxiesStore.list" :key="proxy.ExternalDomain">
|
<tr v-for="proxy in proxiesStore.list" :key="proxy.ExternalDomain">
|
||||||
<td>
|
<td>
|
||||||
<span class="cert-icon" :class="findCertForDomain(proxy.ExternalDomain) ? (findCertForDomain(proxy.ExternalDomain).is_expired ? 'cert-expired' : 'cert-valid') : 'cert-none'" :title="findCertForDomain(proxy.ExternalDomain) ? (findCertForDomain(proxy.ExternalDomain).is_expired ? 'SSL истёк' : `SSL (${findCertForDomain(proxy.ExternalDomain).days_left} дн.)`) : 'Нет сертификата'">
|
<span class="cert-icon" :class="findCertForDomain(proxy.ExternalDomain) ? (findCertForDomain(proxy.ExternalDomain).is_expired ? 'cert-expired' : 'cert-valid') : 'cert-none'">
|
||||||
<i class="fas fa-shield-alt"></i>
|
<i class="fas fa-shield-alt"></i>
|
||||||
</span>
|
</span>
|
||||||
<code class="clickable-link">{{ proxy.ExternalDomain }} <i class="fas fa-external-link-alt"></i></code>
|
{{ proxy.Name || '—' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code class="clickable-link" @click="openUrl(proxy.ExternalDomain)">{{ proxy.ExternalDomain }} <i class="fas fa-external-link-alt"></i></code>
|
||||||
</td>
|
</td>
|
||||||
<td><code>{{ proxy.LocalAddress }}:{{ proxy.LocalPort }}</code></td>
|
<td><code>{{ proxy.LocalAddress }}:{{ proxy.LocalPort }}</code></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -65,11 +76,6 @@ const findCertForDomain = (domain) => {
|
|||||||
{{ proxy.ServiceHTTPSuse ? 'HTTPS' : 'HTTP' }}
|
{{ proxy.ServiceHTTPSuse ? 'HTTPS' : 'HTTP' }}
|
||||||
</VBadge>
|
</VBadge>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<VBadge :variant="proxy.AutoHTTPS ? 'yes' : 'no'">
|
|
||||||
{{ proxy.AutoHTTPS ? t('common.yes') : t('common.no') }}
|
|
||||||
</VBadge>
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
<VBadge :variant="proxy.Enable ? 'online' : 'offline'">
|
<VBadge :variant="proxy.Enable ? 'online' : 'offline'">
|
||||||
{{ proxy.Enable ? 'active' : 'disabled' }}
|
{{ proxy.Enable ? 'active' : 'disabled' }}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ const serviceInfoLabel = {
|
|||||||
<i :class="serviceIcons[service.name] || 'fas fa-server'"></i>
|
<i :class="serviceIcons[service.name] || 'fas fa-server'"></i>
|
||||||
{{ service.name }}
|
{{ service.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<VBadge :variant="service.status ? 'online' : 'offline'">
|
<VBadge :variant="service.pending ? 'pending' : (service.status ? 'online' : 'offline')">
|
||||||
{{ service.status ? t('common.enabled') : t('common.disabled') }}
|
{{ service.pending || (service.status ? t('common.enabled') : t('common.disabled')) }}
|
||||||
</VBadge>
|
</VBadge>
|
||||||
</div>
|
</div>
|
||||||
<div class="service-info">
|
<div class="service-info">
|
||||||
|
|||||||
@@ -6,6 +6,22 @@ const certsStore = useCertsStore()
|
|||||||
const { success, error } = useNotification()
|
const { success, error } = useNotification()
|
||||||
const modal = useModal()
|
const modal = useModal()
|
||||||
|
|
||||||
|
const openingFolder = ref('')
|
||||||
|
|
||||||
|
const openUrl = (host) => {
|
||||||
|
if (window.runtime?.BrowserOpenURL) {
|
||||||
|
window.runtime.BrowserOpenURL('http://' + host)
|
||||||
|
} else {
|
||||||
|
window.open('http://' + host, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openFolder = async (host) => {
|
||||||
|
openingFolder.value = host
|
||||||
|
await sitesStore.openFolder(host)
|
||||||
|
setTimeout(() => { openingFolder.value = '' }, 800)
|
||||||
|
}
|
||||||
|
|
||||||
const findCertForDomain = (domain, aliases = []) => {
|
const findCertForDomain = (domain, aliases = []) => {
|
||||||
const allDomains = [domain, ...aliases.filter(a => !a.includes('*'))]
|
const allDomains = [domain, ...aliases.filter(a => !a.includes('*'))]
|
||||||
for (const d of allDomains) {
|
for (const d of allDomains) {
|
||||||
@@ -42,7 +58,7 @@ const confirmDelete = (site) => {
|
|||||||
warning: t('sites.deleteWarning'),
|
warning: t('sites.deleteWarning'),
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
const result = await sitesStore.remove(site.host)
|
const result = await sitesStore.remove(site.host)
|
||||||
if (result === 'OK') success(t('notify.siteDeleted'))
|
if (result && !String(result).startsWith('Error')) success(t('notify.siteDeleted'))
|
||||||
else error(String(result))
|
else error(String(result))
|
||||||
modal.close()
|
modal.close()
|
||||||
},
|
},
|
||||||
@@ -79,7 +95,7 @@ const confirmDelete = (site) => {
|
|||||||
{{ site.name }}
|
{{ site.name }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<code class="clickable-link">{{ site.host }} <i class="fas fa-external-link-alt"></i></code>
|
<code class="clickable-link" @click="openUrl(site.host)">{{ site.host }} <i class="fas fa-external-link-alt"></i></code>
|
||||||
</td>
|
</td>
|
||||||
<td><code>{{ site.alias?.join(', ') || '—' }}</code></td>
|
<td><code>{{ site.alias?.join(', ') || '—' }}</code></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -89,8 +105,9 @@ const confirmDelete = (site) => {
|
|||||||
</td>
|
</td>
|
||||||
<td><code>{{ site.root_file }}</code></td>
|
<td><code>{{ site.root_file }}</code></td>
|
||||||
<td>
|
<td>
|
||||||
<button class="icon-btn" :title="t('sites.openFolder')" @click="sitesStore.openFolder(site.host)">
|
<button class="icon-btn" :title="t('sites.openFolder')" :disabled="openingFolder === site.host" @click="openFolder(site.host)">
|
||||||
<i class="fas fa-folder-open"></i>
|
<i v-if="openingFolder === site.host" class="fas fa-spinner icon-spin"></i>
|
||||||
|
<i v-else class="fas fa-folder-open"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn" :title="t('sites.editVaccess')" @click="router.push(`/vaccess/${site.host}`)">
|
<button class="icon-btn" :title="t('sites.editVaccess')" @click="router.push(`/vaccess/${site.host}`)">
|
||||||
<i class="fas fa-user-lock"></i>
|
<i class="fas fa-user-lock"></i>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ defineEmits(['click'])
|
|||||||
:disabled="disabled || loading"
|
:disabled="disabled || loading"
|
||||||
@click="$emit('click', $event)"
|
@click="$emit('click', $event)"
|
||||||
>
|
>
|
||||||
<i v-if="loading" class="fas fa-spinner fa-spin"></i>
|
<i v-if="loading" class="fas fa-spinner icon-spin"></i>
|
||||||
<i v-else-if="icon" :class="icon"></i>
|
<i v-else-if="icon" :class="icon"></i>
|
||||||
<span v-if="$slots.default"><slot /></span>
|
<span v-if="$slots.default"><slot /></span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,31 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { useWailsEvents } from '@core/composables/useWailsEvents.js'
|
||||||
|
|
||||||
const { theme, isDark, toggleTheme, initTheme } = useTheme()
|
const { theme, isDark, toggleTheme, initTheme } = useTheme()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const servicesStore = useServicesStore()
|
||||||
|
const { on, off } = useWailsEvents()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initTheme()
|
initTheme()
|
||||||
|
|
||||||
|
on('service:changed', (status) => {
|
||||||
|
if (status && !servicesStore.isOperating) {
|
||||||
|
servicesStore.list = Object.values(status)
|
||||||
|
servicesStore.loaded = true
|
||||||
|
const hasRunning = status.http?.status || status.https?.status
|
||||||
|
appStore.setServerRunning(hasRunning)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
on('server:already_running', () => {
|
||||||
|
appStore.setServerRunning(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
off('service:changed')
|
||||||
|
off('server:already_running')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -53,6 +75,6 @@ onMounted(() => {
|
|||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: var(--space-lg);
|
padding: 40px var(--space-3xl);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ const props = defineProps({
|
|||||||
|
|
||||||
const certs = ref([])
|
const certs = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const issuing = ref('')
|
||||||
|
const renewing = ref('')
|
||||||
|
const deleting = ref('')
|
||||||
|
|
||||||
onMounted(async () => {
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
const refreshCerts = async () => {
|
||||||
await certsStore.loadAll()
|
await certsStore.loadAll()
|
||||||
certs.value = certsStore.list.filter(c =>
|
certs.value = certsStore.list.filter(c =>
|
||||||
c.dns_names?.some(d => d === props.host || d === `*.${props.host}` || props.host.match(new RegExp(d.replace('*.', '.*\\.'))))
|
c.dns_names?.some(d => d === props.host || d === `*.${props.host}` || props.host.match(new RegExp(d.replace('*.', '.*\\.'))))
|
||||||
@@ -19,25 +24,47 @@ onMounted(async () => {
|
|||||||
const info = await certsStore.getInfo(props.host)
|
const info = await certsStore.getInfo(props.host)
|
||||||
if (info && info.has_cert) certs.value = [info]
|
if (info && info.has_cert) certs.value = [info]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refreshCerts()
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
const issueCert = async (domain) => {
|
const issueCert = async (domain) => {
|
||||||
const result = await certsStore.issue(domain)
|
issuing.value = domain
|
||||||
if (result === 'OK') success(t('notify.certIssued'))
|
const [result] = await Promise.all([certsStore.issue(domain), sleep(1000)])
|
||||||
else error(String(result))
|
if (result && !String(result).startsWith('Error')) {
|
||||||
|
success(t('notify.certIssued'))
|
||||||
|
await refreshCerts()
|
||||||
|
} else {
|
||||||
|
error(String(result))
|
||||||
|
}
|
||||||
|
issuing.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const renewCert = async (domain) => {
|
const renewCert = async (domain) => {
|
||||||
const result = await certsStore.renew(domain)
|
renewing.value = domain
|
||||||
if (result === 'OK') success(t('notify.certRenewed'))
|
const [result] = await Promise.all([certsStore.renew(domain), sleep(1000)])
|
||||||
else error(String(result))
|
if (result && !String(result).startsWith('Error')) {
|
||||||
|
success(t('notify.certRenewed'))
|
||||||
|
await refreshCerts()
|
||||||
|
} else {
|
||||||
|
error(String(result))
|
||||||
|
}
|
||||||
|
renewing.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteCert = async (domain) => {
|
const deleteCert = async (domain) => {
|
||||||
const result = await certsStore.remove(domain)
|
deleting.value = domain
|
||||||
if (result === 'OK') success(t('notify.certDeleted'))
|
const [result] = await Promise.all([certsStore.remove(domain), sleep(1000)])
|
||||||
else error(String(result))
|
if (result && !String(result).startsWith('Error')) {
|
||||||
|
success(t('notify.certDeleted'))
|
||||||
|
await refreshCerts()
|
||||||
|
} else {
|
||||||
|
error(String(result))
|
||||||
|
}
|
||||||
|
deleting.value = ''
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -53,7 +80,7 @@ const deleteCert = async (domain) => {
|
|||||||
<i class="fas fa-certificate"></i>
|
<i class="fas fa-certificate"></i>
|
||||||
<h3>{{ t('certs.noCert') }}</h3>
|
<h3>{{ t('certs.noCert') }}</h3>
|
||||||
<p>{{ t('certs.subtitle') }}</p>
|
<p>{{ t('certs.subtitle') }}</p>
|
||||||
<VButton icon="fas fa-plus" @click="issueCert(host)">{{ t('certs.issue') }}</VButton>
|
<VButton icon="fas fa-plus" :loading="issuing === host" @click="issueCert(host)">{{ t('certs.issue') }}</VButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Карточки сертификатов -->
|
<!-- Карточки сертификатов -->
|
||||||
@@ -64,13 +91,13 @@ const deleteCert = async (domain) => {
|
|||||||
<h3>{{ cert.domain }}</h3>
|
<h3>{{ cert.domain }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="cert-card-actions">
|
<div class="cert-card-actions">
|
||||||
<VButton v-if="cert.has_cert && !cert.is_expired" variant="success" icon="fas fa-sync" @click="renewCert(cert.domain)">
|
<VButton v-if="cert.has_cert && !cert.is_expired" variant="success" icon="fas fa-sync" :loading="renewing === cert.domain" @click="renewCert(cert.domain)">
|
||||||
{{ t('certs.renew') }}
|
{{ t('certs.renew') }}
|
||||||
</VButton>
|
</VButton>
|
||||||
<VButton v-if="!cert.has_cert || cert.is_expired" icon="fas fa-plus" @click="issueCert(cert.domain)">
|
<VButton v-if="!cert.has_cert || cert.is_expired" icon="fas fa-plus" :loading="issuing === cert.domain" @click="issueCert(cert.domain)">
|
||||||
{{ t('certs.issue') }}
|
{{ t('certs.issue') }}
|
||||||
</VButton>
|
</VButton>
|
||||||
<VButton v-if="cert.has_cert" variant="danger" icon="fas fa-trash" @click="deleteCert(cert.domain)">
|
<VButton v-if="cert.has_cert" variant="danger" icon="fas fa-trash" :loading="deleting === cert.domain" @click="deleteCert(cert.domain)">
|
||||||
{{ t('certs.delete') }}
|
{{ t('certs.delete') }}
|
||||||
</VButton>
|
</VButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ const certsStore = useCertsStore()
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
servicesStore.load(),
|
|
||||||
sitesStore.load(),
|
sitesStore.load(),
|
||||||
proxiesStore.load(),
|
proxiesStore.load(),
|
||||||
certsStore.loadAll(),
|
certsStore.loadAll(),
|
||||||
])
|
])
|
||||||
|
if (!servicesStore.loaded) {
|
||||||
|
await servicesStore.load()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ const proxiesStore = useProxiesStore()
|
|||||||
const { success, error } = useNotification()
|
const { success, error } = useNotification()
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
domain: '',
|
domain: '',
|
||||||
localAddr: '127.0.0.1',
|
localAddr: '127.0.0.1',
|
||||||
localPort: '',
|
localPort: '',
|
||||||
serviceHttps: false,
|
serviceHttps: false,
|
||||||
autoHttps: true,
|
|
||||||
certMode: 'none',
|
certMode: 'none',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -18,10 +18,23 @@ const creating = ref(false)
|
|||||||
const createProxy = async () => {
|
const createProxy = async () => {
|
||||||
if (!form.domain || !form.localPort) return
|
if (!form.domain || !form.localPort) return
|
||||||
creating.value = true
|
creating.value = true
|
||||||
const result = await proxiesStore.load()
|
const result = await proxiesStore.create({
|
||||||
|
name: form.name,
|
||||||
|
domain: form.domain,
|
||||||
|
localAddr: form.localAddr,
|
||||||
|
localPort: form.localPort,
|
||||||
|
enabled: true,
|
||||||
|
serviceHttps: form.serviceHttps,
|
||||||
|
autoHttps: form.serviceHttps,
|
||||||
|
autoSSL: false,
|
||||||
|
})
|
||||||
creating.value = false
|
creating.value = false
|
||||||
success(t('notify.dataSaved'))
|
if (result && !String(result).startsWith('Error')) {
|
||||||
|
success(t('notify.proxyCreated'))
|
||||||
router.push('/')
|
router.push('/')
|
||||||
|
} else {
|
||||||
|
error(String(result))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -41,6 +54,7 @@ const createProxy = async () => {
|
|||||||
<div class="settings-form">
|
<div class="settings-form">
|
||||||
<h3 class="form-subsection-title"><i class="fas fa-info-circle"></i> {{ t('common.basicInfo') }}</h3>
|
<h3 class="form-subsection-title"><i class="fas fa-info-circle"></i> {{ t('common.basicInfo') }}</h3>
|
||||||
|
|
||||||
|
<VInput v-model="form.name" :label="t('sites.formName')" placeholder="My Proxy" />
|
||||||
<VInput v-model="form.domain" :label="t('proxies.formDomain')" :placeholder="t('proxies.formDomainPlaceholder')" :hint="t('proxies.formDomainHint')" required />
|
<VInput v-model="form.domain" :label="t('proxies.formDomain')" :placeholder="t('proxies.formDomainPlaceholder')" :hint="t('proxies.formDomainHint')" required />
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -48,22 +62,13 @@ const createProxy = async () => {
|
|||||||
<VInput v-model="form.localPort" :label="t('proxies.formLocalPort')" placeholder="3000" required />
|
<VInput v-model="form.localPort" :label="t('proxies.formLocalPort')" placeholder="3000" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="form-label-row">
|
<div class="form-label-row">
|
||||||
<VTooltip :text="t('proxies.formServiceHttpsHint')" />
|
<VTooltip :text="t('proxies.formServiceHttpsHint')" />
|
||||||
<label class="form-label">{{ t('proxies.formServiceHttps') }}</label>
|
<label class="form-label">HTTPS</label>
|
||||||
</div>
|
</div>
|
||||||
<VToggle v-model="form.serviceHttps" :label="t('common.enabled')" />
|
<VToggle v-model="form.serviceHttps" :label="t('common.enabled')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<div class="form-label-row">
|
|
||||||
<VTooltip :text="t('proxies.formAutoHttpsHint')" />
|
|
||||||
<label class="form-label">{{ t('proxies.formAutoHttps') }}</label>
|
|
||||||
</div>
|
|
||||||
<VToggle v-model="form.autoHttps" :label="t('common.enabled')" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SslUploadSection v-model="form.certMode" />
|
<SslUploadSection v-model="form.certMode" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
domain: '',
|
domain: '',
|
||||||
localAddr: '127.0.0.1',
|
localAddr: '127.0.0.1',
|
||||||
localPort: '',
|
localPort: '',
|
||||||
@@ -19,12 +20,15 @@ const form = reactive({
|
|||||||
autoSSL: false,
|
autoSSL: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
import { api } from '@core/api/index.js'
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!proxiesStore.loaded) await proxiesStore.load()
|
if (!proxiesStore.loaded) await proxiesStore.load()
|
||||||
const proxy = proxiesStore.list.find(p => p.ExternalDomain === props.domain)
|
const proxy = proxiesStore.list.find(p => p.ExternalDomain === props.domain)
|
||||||
if (proxy) {
|
if (proxy) {
|
||||||
|
form.name = proxy.Name || ''
|
||||||
form.domain = proxy.ExternalDomain
|
form.domain = proxy.ExternalDomain
|
||||||
form.localAddr = proxy.LocalAddress
|
form.localAddr = proxy.LocalAddress
|
||||||
form.localPort = proxy.LocalPort
|
form.localPort = proxy.LocalPort
|
||||||
@@ -37,9 +41,29 @@ onMounted(async () => {
|
|||||||
|
|
||||||
const saveProxy = async () => {
|
const saveProxy = async () => {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
|
const config = await api.getConfig()
|
||||||
|
const idx = config.Proxy_Service.findIndex(p => p.ExternalDomain === props.domain)
|
||||||
|
if (idx >= 0) {
|
||||||
|
config.Proxy_Service[idx].Name = form.name
|
||||||
|
config.Proxy_Service[idx].ExternalDomain = form.domain
|
||||||
|
config.Proxy_Service[idx].LocalAddress = form.localAddr
|
||||||
|
config.Proxy_Service[idx].LocalPort = form.localPort
|
||||||
|
config.Proxy_Service[idx].Enable = form.enabled
|
||||||
|
config.Proxy_Service[idx].ServiceHTTPSuse = form.serviceHttps
|
||||||
|
config.Proxy_Service[idx].AutoHTTPS = form.serviceHttps
|
||||||
|
config.Proxy_Service[idx].AutoCreateSSL = form.autoSSL
|
||||||
|
const result = await api.saveConfig(JSON.stringify(config))
|
||||||
|
if (!String(result).startsWith('Error')) {
|
||||||
|
await proxiesStore.load()
|
||||||
success(t('notify.dataSaved'))
|
success(t('notify.dataSaved'))
|
||||||
saving.value = false
|
|
||||||
router.push('/')
|
router.push('/')
|
||||||
|
} else {
|
||||||
|
error(result)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error('Proxy not found in config')
|
||||||
|
}
|
||||||
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
@@ -47,9 +71,14 @@ const confirmDelete = () => {
|
|||||||
title: t('proxies.deleteTitle'),
|
title: t('proxies.deleteTitle'),
|
||||||
message: t('proxies.deleteConfirm', { domain: form.domain }),
|
message: t('proxies.deleteConfirm', { domain: form.domain }),
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
|
const result = await proxiesStore.remove(form.domain)
|
||||||
|
if (result && !String(result).startsWith('Error')) {
|
||||||
success(t('notify.proxyDeleted'))
|
success(t('notify.proxyDeleted'))
|
||||||
modal.close()
|
|
||||||
router.push('/')
|
router.push('/')
|
||||||
|
} else {
|
||||||
|
error(String(result))
|
||||||
|
}
|
||||||
|
modal.close()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -82,6 +111,10 @@ const confirmDelete = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Имя и домен -->
|
||||||
|
<VInput v-model="form.name" :label="t('sites.formName')" />
|
||||||
|
<VInput v-model="form.domain" :label="t('proxies.externalDomain')" required />
|
||||||
|
|
||||||
<!-- Адрес и порт -->
|
<!-- Адрес и порт -->
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<VInput v-model="form.localAddr" :label="t('proxies.formLocalAddr')" required />
|
<VInput v-model="form.localAddr" :label="t('proxies.formLocalAddr')" required />
|
||||||
@@ -89,15 +122,11 @@ const confirmDelete = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toggles -->
|
<!-- Toggles -->
|
||||||
<div class="form-row form-row-3">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">{{ t('proxies.formServiceHttps') }}:</label>
|
<label class="form-label">HTTPS:</label>
|
||||||
<VToggle v-model="form.serviceHttps" :label="t('common.enabled')" />
|
<VToggle v-model="form.serviceHttps" :label="t('common.enabled')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">{{ t('proxies.formAutoHttps') }}:</label>
|
|
||||||
<VToggle v-model="form.autoHttps" :label="t('common.enabled')" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Auto SSL:</label>
|
<label class="form-label">Auto SSL:</label>
|
||||||
<VToggle v-model="form.autoSSL" :label="t('common.enabled')" />
|
<VToggle v-model="form.autoSSL" :label="t('common.enabled')" />
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const createSite = async () => {
|
|||||||
}
|
}
|
||||||
const result = await sitesStore.create(siteData)
|
const result = await sitesStore.create(siteData)
|
||||||
creating.value = false
|
creating.value = false
|
||||||
if (result === 'OK') {
|
if (result && !String(result).startsWith('Error')) {
|
||||||
success(t('notify.siteCreated'))
|
success(t('notify.siteCreated'))
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const form = reactive({
|
|||||||
autoSSL: false,
|
autoSSL: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
import { api } from '@core/api/index.js'
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const aliasInput = ref('')
|
const aliasInput = ref('')
|
||||||
|
|
||||||
@@ -55,9 +57,28 @@ const removeAlias = (index) => {
|
|||||||
|
|
||||||
const saveSite = async () => {
|
const saveSite = async () => {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
|
const config = await api.getConfig()
|
||||||
|
const idx = config.Site_www.findIndex(s => s.host === props.host)
|
||||||
|
if (idx >= 0) {
|
||||||
|
config.Site_www[idx].host = form.host
|
||||||
|
config.Site_www[idx].name = form.name
|
||||||
|
config.Site_www[idx].alias = form.alias
|
||||||
|
config.Site_www[idx].root_file = form.rootFile
|
||||||
|
config.Site_www[idx].status = form.status
|
||||||
|
config.Site_www[idx].root_file_routing = form.routing
|
||||||
|
config.Site_www[idx].AutoCreateSSL = form.autoSSL
|
||||||
|
const result = await api.saveConfig(JSON.stringify(config))
|
||||||
|
if (!String(result).startsWith('Error')) {
|
||||||
|
await sitesStore.load()
|
||||||
success(t('notify.dataSaved'))
|
success(t('notify.dataSaved'))
|
||||||
saving.value = false
|
|
||||||
router.push('/')
|
router.push('/')
|
||||||
|
} else {
|
||||||
|
error(result)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error('Site not found in config')
|
||||||
|
}
|
||||||
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
@@ -67,7 +88,7 @@ const confirmDelete = () => {
|
|||||||
warning: t('sites.deleteWarning'),
|
warning: t('sites.deleteWarning'),
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
const result = await sitesStore.remove(form.host)
|
const result = await sitesStore.remove(form.host)
|
||||||
if (result === 'OK') {
|
if (result && !String(result).startsWith('Error')) {
|
||||||
success(t('notify.siteDeleted'))
|
success(t('notify.siteDeleted'))
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} else {
|
} else {
|
||||||
@@ -107,6 +128,7 @@ const confirmDelete = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Основная информация -->
|
<!-- Основная информация -->
|
||||||
|
<VInput v-model="form.host" :label="t('sites.host')" required />
|
||||||
<VInput v-model="form.name" :label="t('sites.formName')" required />
|
<VInput v-model="form.name" :label="t('sites.formName')" required />
|
||||||
|
|
||||||
<!-- Alias с тегами -->
|
<!-- Alias с тегами -->
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { api } from '@core/api/index.js'
|
import { api } from '@core/api/index.js'
|
||||||
|
import { useDraggable } from '@core/composables/useDraggable.js'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -13,6 +14,8 @@ const activeTab = ref('rules')
|
|||||||
const rules = ref([])
|
const rules = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const { dragIndex, dragOverIndex, onDragStart, onDragOver, onDragEnter, onDragLeave, onDrop, onDragEnd } = useDraggable(rules)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const data = await api.getVAccessRules(props.host, false)
|
const data = await api.getVAccessRules(props.host, false)
|
||||||
rules.value = data?.rules || []
|
rules.value = data?.rules || []
|
||||||
@@ -91,7 +94,18 @@ const formatList = (arr) => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(rule, index) in rules" :key="index">
|
<tr
|
||||||
|
v-for="(rule, index) in rules"
|
||||||
|
:key="index"
|
||||||
|
draggable="true"
|
||||||
|
:class="{ 'drag-over': dragOverIndex === index, 'dragging': dragIndex === index }"
|
||||||
|
@dragstart="onDragStart(index, $event)"
|
||||||
|
@dragover="onDragOver(index, $event)"
|
||||||
|
@dragenter="onDragEnter(index, $event)"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop="onDrop(index)"
|
||||||
|
@dragend="onDragEnd($event)"
|
||||||
|
>
|
||||||
<td class="drag-handle"><i class="fas fa-grip-vertical"></i></td>
|
<td class="drag-handle"><i class="fas fa-grip-vertical"></i></td>
|
||||||
<td>
|
<td>
|
||||||
<VBadge :variant="rule.type === 'Allow' ? 'yes' : 'no'">{{ rule.type }}</VBadge>
|
<VBadge :variant="rule.type === 'Allow' ? 'yes' : 'no'">{{ rule.type }}</VBadge>
|
||||||
@@ -310,6 +324,18 @@ const formatList = (arr) => {
|
|||||||
color: var(--accent-purple-light);
|
color: var(--accent-purple-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vaccess-table tbody tr.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vaccess-table tbody tr.drag-over {
|
||||||
|
border-top: 2px solid var(--accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vaccess-table tbody tr.drag-over td {
|
||||||
|
border-top: 2px solid var(--accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
.mini-tags {
|
.mini-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -12,7 +12,7 @@ import (
|
|||||||
admin "vServer/Backend/admin/go"
|
admin "vServer/Backend/admin/go"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:Backend/admin/frontend
|
//go:embed all:front_vue/dist
|
||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
12
wails.json
12
wails.json
@@ -13,10 +13,14 @@
|
|||||||
"copyright": "Copyright © 2025 vServer",
|
"copyright": "Copyright © 2025 vServer",
|
||||||
"comments": "Панель управления vServer"
|
"comments": "Панель управления vServer"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./Backend/admin/frontend/wailsjs",
|
"wailsjsdir": "./front_vue/wailsjs",
|
||||||
"frontend:dir": "./Backend/admin/frontend",
|
"frontend:dir": "./front_vue",
|
||||||
"assetdir": "./Backend/admin/frontend",
|
"frontend:install": "npm install",
|
||||||
"reloaddirs": "Backend/admin",
|
"frontend:build": "npm run build",
|
||||||
|
"frontend:dev:watcher": "npm run dev",
|
||||||
|
"frontend:dev:serverUrl": "http://localhost:4444",
|
||||||
|
"assetdir": "./front_vue/dist",
|
||||||
|
"reloaddirs": "./front_vue/src",
|
||||||
"build:dir": ".",
|
"build:dir": ".",
|
||||||
"appicon": "appicon.png"
|
"appicon": "appicon.png"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user