Улучшен фронт

1. Добавлен функционал в интерфейс по управлению сертификатами и службой редактирования сертификатов.

2. Добавлена кнопка для добавления прокси и экран редактирования прокси.
This commit is contained in:
2026-01-17 11:57:57 +07:00
parent 9a788800b5
commit 05ddb1e796
22 changed files with 1641 additions and 77 deletions

View File

@@ -13,6 +13,7 @@ import { SitesManager } from './components/sites.js';
import { ProxyManager } from './components/proxy.js';
import { VAccessManager } from './components/vaccess.js';
import { SiteCreator } from './components/site-creator.js';
import { ProxyCreator } from './components/proxy-creator.js';
import { api } from './api/wails.js';
import { configAPI } from './api/config.js';
import { initCustomSelects } from './ui/custom-select.js';
@@ -28,6 +29,7 @@ class App {
this.proxyManager = new ProxyManager();
this.vAccessManager = new VAccessManager();
this.siteCreator = new SiteCreator();
this.proxyCreator = new ProxyCreator();
this.isWails = isWailsAvailable();
}
@@ -109,6 +111,14 @@ class App {
});
}
// Кнопка добавления прокси
const addProxyBtn = $('addProxyBtn');
if (addProxyBtn) {
addProxyBtn.addEventListener('click', () => {
this.proxyCreator.open();
});
}
// Кнопка сохранения настроек
const saveSettingsBtn = $('saveSettingsBtn');
if (saveSettingsBtn) {
@@ -135,6 +145,22 @@ class App {
}
});
}
// Моментальное переключение ACME без перезапуска
const acmeCheckbox = $('acmeEnabled');
if (acmeCheckbox) {
acmeCheckbox.addEventListener('change', async (e) => {
const isEnabled = e.target.checked;
if (isEnabled) {
await configAPI.enableACMEService();
notification.success('Cert Manager включен', 1000);
} else {
await configAPI.disableACMEService();
notification.success('Cert Manager отключен', 1000);
}
});
}
}
// Настроить глобальные обработчики
@@ -143,15 +169,29 @@ class App {
// Ссылки на менеджеры
sitesManager: this.sitesManager,
siteCreator: this.siteCreator,
proxyCreator: this.proxyCreator,
proxyManager: this.proxyManager,
// SiteCreator
backToMainFromAddSite: () => this.siteCreator.backToMain(),
toggleCertUpload: () => this.siteCreator.toggleCertUpload(),
handleCertFileSelect: (input, certType) => this.siteCreator.handleCertFile(input, certType),
// ProxyCreator
backToMainFromAddProxy: () => this.proxyCreator.backToMain(),
toggleProxyCertUpload: () => this.proxyCreator.toggleCertUpload(),
handleProxyCertFileSelect: (input, certType) => this.proxyCreator.handleCertFile(input, certType),
// vAccess
editVAccess: (host, isProxy) => this.vAccessManager.open(host, isProxy),
backToMain: () => this.vAccessManager.backToMain(),
// CertManager
openCertManager: (host, isProxy, aliases) => this.openCertManager(host, isProxy, aliases),
backFromCertManager: () => this.backFromCertManager(),
deleteCertificate: async (domain) => await this.deleteCertificate(domain),
renewCertificate: async (domain) => await this.renewCertificate(domain),
issueCertificate: async (domain) => await this.issueCertificate(domain),
switchVAccessTab: (tab) => this.vAccessManager.switchTab(tab),
saveVAccessChanges: async () => await this.vAccessManager.save(),
addVAccessRule: () => this.vAccessManager.addRule(),
@@ -209,6 +249,7 @@ class App {
$('phpHost').value = 'localhost';
$('phpPort').value = 8000;
$('proxyEnabled').checked = true;
$('acmeEnabled').checked = true;
return;
}
@@ -220,6 +261,7 @@ class App {
$('phpHost').value = config.Soft_Settings?.php_host || 'localhost';
$('phpPort').value = config.Soft_Settings?.php_port || 8000;
$('proxyEnabled').checked = config.Soft_Settings?.proxy_enabled !== false;
$('acmeEnabled').checked = config.Soft_Settings?.ACME_enabled !== false;
}
// Сохранить настройки конфигурации
@@ -294,6 +336,9 @@ class App {
if (editRootFile) editRootFile.value = site.root_file;
if (editRouting) editRouting.checked = site.root_file_routing;
const editAutoCreateSSL = $('editAutoCreateSSL');
if (editAutoCreateSSL) editAutoCreateSSL.checked = site.auto_create_ssl || false;
// Добавляем alias теги
const aliasContainer = $('aliasTagsContainer');
if (aliasContainer) {
@@ -348,6 +393,9 @@ class App {
if (editServiceHTTPS) editServiceHTTPS.checked = proxy.service_https_use;
if (editAutoHTTPS) editAutoHTTPS.checked = proxy.auto_https;
const editProxyAutoCreateSSL = $('editProxyAutoCreateSSL');
if (editProxyAutoCreateSSL) editProxyAutoCreateSSL.checked = proxy.auto_create_ssl || false;
// Привязываем обработчик кнопок статуса
document.querySelectorAll('.status-btn').forEach(btn => {
btn.onclick = () => this.setModalStatus(btn.dataset.value);
@@ -425,7 +473,8 @@ class App {
alias: aliases,
status: statusBtn ? statusBtn.dataset.value : 'active',
root_file: $('editRootFile').value,
root_file_routing: $('editRouting').checked
root_file_routing: $('editRouting').checked,
AutoCreateSSL: $('editAutoCreateSSL')?.checked || false
};
const result = await configAPI.saveConfig(JSON.stringify(config, null, 4));
@@ -453,7 +502,8 @@ class App {
LocalAddress: $('editLocalAddr').value,
LocalPort: $('editLocalPort').value,
ServiceHTTPSuse: $('editServiceHTTPS').checked,
AutoHTTPS: $('editAutoHTTPS').checked
AutoHTTPS: $('editAutoHTTPS').checked,
AutoCreateSSL: $('editProxyAutoCreateSSL')?.checked || false
};
const result = await configAPI.saveConfig(JSON.stringify(config, null, 4));
@@ -541,6 +591,404 @@ class App {
notification.error('Ошибка: ' + error.message, 3000);
}
}
// ====== Cert Manager ======
certManagerHost = null;
certManagerIsProxy = false;
certManagerAliases = [];
async openCertManager(host, isProxy = false, aliases = []) {
this.certManagerHost = host;
this.certManagerIsProxy = isProxy;
this.certManagerAliases = aliases.filter(a => !a.includes('*'));
// Обновляем заголовки
$('certManagerBreadcrumb').textContent = `Сертификаты: ${host}`;
const titleSpan = $('certManagerTitle').querySelector('span');
if (titleSpan) titleSpan.textContent = host;
$('certManagerSubtitle').textContent = isProxy ? 'Прокси сервис' : 'Веб-сайт';
// Скрываем все секции, показываем CertManager
this.hideAllSectionsForCertManager();
$('sectionCertManager').style.display = 'block';
// Загружаем сертификаты
await this.loadCertManagerContent(host, this.certManagerAliases);
}
hideAllSectionsForCertManager() {
const sections = ['sectionServices', 'sectionSites', 'sectionProxy', 'sectionSettings', 'sectionVAccessEditor', 'sectionAddSite', 'sectionCertManager'];
sections.forEach(id => {
const el = $(id);
if (el) el.style.display = 'none';
});
}
// Тестовые данные для сертификатов (браузерный режим)
mockCertsData = [
{
domain: 'voxsel.ru',
issuer: 'R13',
not_before: '2026-01-07',
not_after: '2026-04-07',
days_left: 79,
is_expired: false,
has_cert: true,
dns_names: ['*.voxsel.com', '*.voxsel.ru', 'voxsel.com', 'voxsel.ru']
},
{
domain: 'finance.voxsel.ru',
issuer: 'E8',
not_before: '2026-01-17',
not_after: '2026-04-17',
days_left: 89,
is_expired: false,
has_cert: true,
dns_names: ['finance.voxsel.ru']
},
{
domain: 'test.local',
issuer: "Let's Encrypt",
not_before: '2025-01-01',
not_after: '2025-03-31',
days_left: 73,
is_expired: false,
has_cert: true,
dns_names: ['test.local', '*.test.local', 'test.com']
},
{
domain: 'api.example.com',
issuer: "Let's Encrypt",
not_before: '2024-10-01',
not_after: '2024-12-30',
days_left: -18,
is_expired: true,
has_cert: true,
dns_names: ['api.example.com', '*.api.example.com']
}
];
async loadCertManagerContent(host, aliases = []) {
const container = $('certManagerContent');
container.innerHTML = '<div style="text-align: center; padding: 40px; color: var(--text-muted);"><i class="fas fa-spinner fa-spin"></i> Загрузка...</div>';
try {
// Получаем сертификаты (реальные или mock)
let allCerts;
if (this.isWails) {
allCerts = await api.getAllCertsInfo();
} else {
allCerts = this.mockCertsData;
}
// Все домены для отображения (host + алиасы без wildcard)
const allDomains = [host, ...aliases.filter(a => !a.includes('*'))];
// Функция проверки wildcard покрытия
const isWildcardCovering = (domain, cert) => {
const parts = domain.split('.');
if (parts.length < 2) return false;
const wildcardPattern = '*.' + parts.slice(1).join('.');
return cert.domain === wildcardPattern ||
cert.domain.startsWith('*.') && domain.endsWith(cert.domain.slice(1)) ||
cert.dns_names?.some(dns => dns === wildcardPattern || (dns.startsWith('*.') && domain.endsWith(dns.slice(1))));
};
// Функция проверки прямого сертификата
const hasDirectCert = (domain, cert) => {
return cert.domain === domain || cert.dns_names?.includes(domain);
};
// Собираем информацию по каждому домену
const domainInfos = allDomains.map(domain => {
const directCert = allCerts.find(cert => hasDirectCert(domain, cert));
const wildcardCert = allCerts.find(cert => isWildcardCovering(domain, cert));
return { domain, directCert, wildcardCert, isLocal: this.isLocalDomain(domain) };
});
let html = '';
// Карточки для каждого домена
domainInfos.forEach(info => {
if (info.isLocal) {
// Локальный домен - только информация
html += `
<div class="cert-card cert-card-local">
<div class="cert-card-header">
<div class="cert-card-title">
<i class="fas fa-home" style="opacity: 0.4"></i>
<h3>${info.domain}</h3>
</div>
</div>
<div class="cert-info-grid">
<div class="cert-info-item">
<div class="cert-info-label">Статус</div>
<div class="cert-info-value" style="opacity: 0.6">Локальный домен</div>
</div>
</div>
</div>
`;
} else if (info.directCert) {
// Есть прямой сертификат
html += this.renderCertCard(info.directCert, info.domain);
} else if (info.wildcardCert) {
// Покрыт wildcard - показываем с возможностью выпустить прямой
html += this.renderDomainWithWildcard(info.domain, info.wildcardCert);
} else {
// Нет сертификата - предлагаем выпустить
html += this.renderNoCertCard(info.domain);
}
});
if (!html) {
html = `
<div class="cert-empty">
<i class="fas fa-shield-alt"></i>
<h3>Нет доменов для отображения</h3>
</div>
`;
}
container.innerHTML = html;
} catch (error) {
container.innerHTML = `<div class="cert-empty"><p>Ошибка загрузки: ${error.message}</p></div>`;
}
}
renderCertCard(cert, displayDomain = null) {
const isExpired = cert.is_expired;
const statusClass = isExpired ? 'expired' : 'valid';
const statusText = isExpired ? 'Истёк' : `Активен (${cert.days_left} дн.)`;
const iconClass = isExpired ? 'expired' : '';
const title = displayDomain || cert.domain;
const dnsNames = cert.dns_names || [cert.domain];
const domainTags = dnsNames.map(d => `<span class="cert-domain-tag">${d}</span>`).join('');
return `
<div class="cert-card">
<div class="cert-card-header">
<div class="cert-card-title ${iconClass}">
<i class="fas fa-shield-alt"></i>
<h3>${title}</h3>
</div>
<div class="cert-card-actions">
<button class="action-btn" onclick="renewCertificate('${title}')">
<i class="fas fa-sync-alt"></i> Перевыпустить
</button>
<button class="action-btn delete-btn" onclick="deleteCertificate('${cert.domain}')">
<i class="fas fa-trash"></i> Удалить
</button>
</div>
</div>
<div class="cert-info-grid">
<div class="cert-info-item">
<div class="cert-info-label">Статус</div>
<div class="cert-info-value ${statusClass}">${statusText}</div>
</div>
<div class="cert-info-item">
<div class="cert-info-label">Издатель</div>
<div class="cert-info-value">${cert.issuer || 'Неизвестно'}</div>
</div>
<div class="cert-info-item">
<div class="cert-info-label">Выдан</div>
<div class="cert-info-value">${cert.not_before || '-'}</div>
</div>
<div class="cert-info-item">
<div class="cert-info-label">Истекает</div>
<div class="cert-info-value ${statusClass}">${cert.not_after || '-'}</div>
</div>
</div>
<div class="cert-domains-list">
${domainTags}
</div>
</div>
`;
}
isLocalDomain(host) {
const localPatterns = [
'localhost',
'127.0.0.1',
'0.0.0.0',
'::1',
'.local',
'.localhost',
'.test',
'.example',
'.invalid'
];
const hostLower = host.toLowerCase();
return localPatterns.some(pattern => {
if (pattern.startsWith('.')) {
return hostLower.endsWith(pattern) || hostLower === pattern.slice(1);
}
return hostLower === pattern;
});
}
renderNoCertCard(host) {
return `
<div class="cert-card cert-card-empty">
<div class="cert-card-header">
<div class="cert-card-title">
<i class="fas fa-shield-alt" style="opacity: 0.4"></i>
<h3>${host}</h3>
</div>
<div class="cert-card-actions">
<button class="action-btn btn-success" onclick="issueCertificate('${host}')">
<i class="fas fa-plus"></i> Выпустить сертификат
</button>
</div>
</div>
<div class="cert-info-grid">
<div class="cert-info-item">
<div class="cert-info-label">Статус</div>
<div class="cert-info-value" style="opacity: 0.6">Нет сертификата</div>
</div>
</div>
<div class="cert-domains-list">
<span class="cert-domain-tag">${host}</span>
</div>
</div>
`;
}
renderDomainWithWildcard(domain, wildcardCert) {
const isExpired = wildcardCert.is_expired;
const statusClass = isExpired ? 'expired' : 'valid';
const statusText = isExpired ? `Покрыт wildcard (истёк)` : `Покрыт wildcard (${wildcardCert.days_left} дн.)`;
return `
<div class="cert-card cert-card-wildcard">
<div class="cert-card-header">
<div class="cert-card-title ${isExpired ? 'expired' : ''}">
<i class="fas fa-shield-alt"></i>
<h3>${domain}</h3>
</div>
<div class="cert-card-actions">
<button class="action-btn btn-success" onclick="issueCertificate('${domain}')">
<i class="fas fa-plus"></i> Выпустить прямой
</button>
</div>
</div>
<div class="cert-info-grid">
<div class="cert-info-item">
<div class="cert-info-label">Статус</div>
<div class="cert-info-value ${statusClass}">${statusText}</div>
</div>
<div class="cert-info-item">
<div class="cert-info-label">Wildcard</div>
<div class="cert-info-value">${wildcardCert.domain}</div>
</div>
</div>
<div class="cert-domains-list">
<span class="cert-domain-tag">${domain}</span>
</div>
</div>
`;
}
async issueCertificate(domain) {
const confirmed = confirm(`Выпустить сертификат для "${domain}"?\n\nБудет запрошен сертификат Let's Encrypt.`);
if (!confirmed) return;
try {
notification.show('Запрос сертификата...', 'info', 2000);
if (this.isWails) {
await api.obtainSSLCertificate(domain);
} else {
// Mock для браузерного режима
await new Promise(r => setTimeout(r, 1500));
}
notification.success('Сертификат успешно выпущен!', 2000);
// Перезагружаем контент
await this.loadCertManagerContent(this.certManagerHost);
// Обновляем списки сайтов и прокси
await this.sitesManager.load();
await this.proxyManager.load();
} catch (error) {
notification.error('Ошибка: ' + error.message, 3000);
}
}
backFromCertManager() {
this.hideAllSectionsForCertManager();
// Показываем секции Dashboard
const dashboard = ['sectionServices', 'sectionSites', 'sectionProxy'];
dashboard.forEach(id => {
const el = $(id);
if (el) el.style.display = 'block';
});
// Убираем active у всех nav-item и ставим на dashboard
document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
const dashboardBtn = document.querySelector('.nav-item[data-page="dashboard"]');
if (dashboardBtn) dashboardBtn.classList.add('active');
}
async deleteCertificate(domain) {
const confirmed = confirm(`Удалить сертификат для "${domain}"?\n\nЭто действие необратимо.`);
if (!confirmed) return;
try {
await api.deleteCertificate(domain);
notification.success('Сертификат удалён', 1500);
// Перезагружаем контент
await this.loadCertManagerContent(this.certManagerHost);
// Обновляем списки сайтов и прокси
await this.sitesManager.load();
await this.proxyManager.load();
} catch (error) {
notification.error('Ошибка: ' + error.message, 3000);
}
}
async renewCertificate(domain) {
const confirmed = confirm(`Перевыпустить сертификат для "${domain}"?\n\nТекущий сертификат будет заменён новым.`);
if (!confirmed) return;
try {
notification.show('Запрос сертификата...', 'info', 2000);
if (this.isWails) {
await api.obtainSSLCertificate(domain);
} else {
// Mock для браузерного режима
await new Promise(r => setTimeout(r, 1500));
}
notification.success('Сертификат успешно перевыпущен!', 2000);
// Перезагружаем контент
await this.loadCertManagerContent(this.certManagerHost);
// Обновляем списки сайтов и прокси
await this.sitesManager.load();
await this.proxyManager.load();
} catch (error) {
notification.error('Ошибка: ' + error.message, 3000);
}
}
}
// Инициализация приложения при загрузке DOM