14.04.2026 Update
This commit is contained in:
407
public/app.js
407
public/app.js
@@ -1,17 +1,13 @@
|
||||
'use strict';
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const state = {
|
||||
interfaces: [], // latest data from /api/interfaces
|
||||
pending: [], // interface names with pending config
|
||||
configModal: null, // name of interface being configured (null = new VLAN)
|
||||
configModalParent: null, // parent interface when creating a new VLAN
|
||||
nat: null, // {installed, interfaces} from /api/nat
|
||||
interfaces: [],
|
||||
pending: [],
|
||||
configModal: null,
|
||||
configModalParent: null,
|
||||
nat: null,
|
||||
};
|
||||
|
||||
// ── API helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
@@ -30,8 +26,6 @@ const get = (path) => api('GET', path);
|
||||
const post = (path, body) => api('POST', path, body);
|
||||
const del = (path) => api('DELETE', path);
|
||||
|
||||
// ── VLAN helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function isVLAN(name) {
|
||||
return /\.\d+$/.test(name);
|
||||
}
|
||||
@@ -43,8 +37,6 @@ function vlanId(name) {
|
||||
return m ? parseInt(m[1]) : 0;
|
||||
}
|
||||
|
||||
// ── Format helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (n === undefined || n === null) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
@@ -64,8 +56,6 @@ function modeLabel(m) {
|
||||
return { dhcp: 'DHCP', static: 'Static', loopback: 'Loopback', manual: 'Manual' }[m] || (m || '?');
|
||||
}
|
||||
|
||||
// ── SVG icons ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ICON = {
|
||||
pencil: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
@@ -83,13 +73,42 @@ const ICON = {
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
function ipClass(ip) {
|
||||
if (!ip) return '';
|
||||
const p = ip.split('.').map(Number);
|
||||
if (p.length !== 4 || p.some(isNaN)) return '';
|
||||
if (p[0] >= 1 && p[0] <= 126) return 'A';
|
||||
if (p[0] === 127) return 'loopback';
|
||||
if (p[0] >= 128 && p[0] <= 191) return 'B';
|
||||
if (p[0] >= 192 && p[0] <= 223) return 'C';
|
||||
return '';
|
||||
}
|
||||
|
||||
function guessMask(ip) {
|
||||
const c = ipClass(ip);
|
||||
switch (c) {
|
||||
case 'A': return '255.0.0.0';
|
||||
case 'B': return '255.255.0.0';
|
||||
case 'C': return '255.255.255.0';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function maskToCIDR(mask) {
|
||||
if (!mask) return '';
|
||||
if (/^\d+$/.test(mask)) return '/' + mask;
|
||||
const parts = mask.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some(isNaN)) return '/' + mask;
|
||||
const bits = parts.map(p => (p >>> 0).toString(2).padStart(8, '0')).join('');
|
||||
const cidr = bits.split('0')[0].length;
|
||||
if (cidr > 0 && cidr <= 32) return '/' + cidr;
|
||||
return '/' + mask;
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
const grid = document.getElementById('ifaceGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
// Group VLANs by parent
|
||||
const vlansByParent = {};
|
||||
const physicals = [];
|
||||
|
||||
@@ -103,9 +122,38 @@ function renderAll() {
|
||||
}
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'iface-table-wrap';
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.className = 'iface-table';
|
||||
|
||||
table.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-if-state"></th>
|
||||
<th class="col-if-name">Интерфейс</th>
|
||||
<th class="col-if-type">Тип</th>
|
||||
<th class="col-if-ipv4">IPv4</th>
|
||||
<th class="col-if-gw">Шлюз</th>
|
||||
<th class="col-if-mode">Режим</th>
|
||||
<th class="col-if-traffic">Трафик</th>
|
||||
<th class="col-if-actions">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ifaceTableBody"></tbody>
|
||||
`;
|
||||
|
||||
wrap.appendChild(table);
|
||||
grid.appendChild(wrap);
|
||||
|
||||
const tbody = document.getElementById('ifaceTableBody');
|
||||
|
||||
for (const iface of physicals) {
|
||||
const isLo = iface.name === 'lo' || iface.mode === 'loopback';
|
||||
if (isLo) continue;
|
||||
const vlans = vlansByParent[iface.name] || [];
|
||||
grid.appendChild(buildCard(iface, vlans));
|
||||
tbody.appendChild(buildPhysicalRow(iface, vlans));
|
||||
}
|
||||
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
@@ -114,131 +162,126 @@ function renderAll() {
|
||||
renderPendingBanner();
|
||||
}
|
||||
|
||||
function buildCard(iface, vlans) {
|
||||
function buildPhysicalRow(iface, vlans) {
|
||||
const hasPending = state.pending.includes(iface.name);
|
||||
const sc = stateClass(iface.state);
|
||||
const isLo = iface.name === 'lo' || iface.mode === 'loopback';
|
||||
const isUp = iface.state === 'up';
|
||||
const isWAN = iface.type === 'wan';
|
||||
const label = iface.label || '';
|
||||
const ipDisplay = iface.ipv4
|
||||
? iface.ipv4 + (iface.ipv4_mask ? maskToCIDR(iface.ipv4_mask) : '')
|
||||
: '<span class="none">—</span>';
|
||||
const gwDisplay = iface.gateway
|
||||
? iface.gateway
|
||||
: '<span class="none">—</span>';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'iface-card' + (hasPending ? ' has-pending' : '');
|
||||
card.dataset.name = iface.name;
|
||||
const trafficDisplay = `
|
||||
<div class="traffic-mini">
|
||||
<span class="traffic-mini-item"><span class="traffic-mini-label">↓</span> ${fmtBytes(iface.rx_bytes)}</span>
|
||||
<span class="traffic-mini-item"><span class="traffic-mini-label">↑</span> ${fmtBytes(iface.tx_bytes)}</span>
|
||||
</div>`;
|
||||
|
||||
const ipv6lines = (iface.ipv6 || []).map(a =>
|
||||
`<div class="info-row"><span class="info-label">IPv6</span><span class="info-val">${a}</span></div>`
|
||||
).join('');
|
||||
const nameCell = label
|
||||
? `<div class="iface-name-stack"><span class="iface-label-text">${label}</span><span class="iface-name-sub">${iface.name}</span></div>`
|
||||
: `<span class="iface-name-text">${iface.name}</span>`;
|
||||
|
||||
const nameBlock = label
|
||||
? `<div class="card-name-stack">
|
||||
<span class="card-label-text">${label}</span>
|
||||
<span class="card-iface-sub">${iface.name}</span>
|
||||
</div>`
|
||||
: `<span class="card-iface-name">${iface.name}</span>`;
|
||||
const typeBadge = isWAN
|
||||
? '<span class="type-badge type-wan">WAN</span>'
|
||||
: '<span class="type-badge type-lan">LAN</span>';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-header">
|
||||
<div class="card-name">
|
||||
<span class="state-dot ${sc}"></span>
|
||||
${nameBlock}
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'iface-row' + (hasPending ? ' has-pending' : '') + (isWAN ? ' row-wan' : '');
|
||||
tr.dataset.name = iface.name;
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="col-if-state"><span class="state-dot ${sc}"></span></td>
|
||||
<td class="col-if-name">
|
||||
<div class="iface-name-block">
|
||||
${nameCell}
|
||||
${hasPending ? '<span class="pending-badge">несохранено</span>' : ''}
|
||||
<button class="btn btn-ghost btn-xs${isWAN ? ' hidden' : ''}" data-action="addvlan" data-iface="${iface.name}" style="margin-left:8px">${ICON.plus} VLAN</button>
|
||||
</div>
|
||||
<span class="mode-badge ${iface.mode || 'unknown'}">${modeLabel(iface.mode)}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">IPv4</span>
|
||||
<span class="info-val">${iface.ipv4
|
||||
? iface.ipv4 + (iface.ipv4_mask ? ' / ' + iface.ipv4_mask : '')
|
||||
: '<span class="none">—</span>'}</span>
|
||||
</td>
|
||||
<td class="col-if-type">${typeBadge}</td>
|
||||
<td class="col-if-ipv4 mono">${ipDisplay}</td>
|
||||
<td class="col-if-gw mono">${gwDisplay}</td>
|
||||
<td class="col-if-mode"><span class="mode-badge ${iface.mode || 'unknown'}">${modeLabel(iface.mode)}</span></td>
|
||||
<td class="col-if-traffic">${trafficDisplay}</td>
|
||||
<td class="col-if-actions">
|
||||
<div class="iface-actions">
|
||||
<label class="toggle-label iface-power-toggle" title="${isUp ? 'Выключить интерфейс' : 'Включить интерфейс'}">
|
||||
<input type="checkbox" ${isUp ? 'checked' : ''} data-action="toggle" data-iface="${iface.name}">
|
||||
<span class="toggle-slider"></span>
|
||||
<span class="iface-toggle-label">${isUp ? 'Вкл' : 'Выкл'}</span>
|
||||
</label>
|
||||
<button class="btn-icon" data-action="restart" data-iface="${iface.name}" title="Перезапустить">${ICON.restart}</button>
|
||||
<button class="btn-icon btn-icon-accent" data-action="config" data-iface="${iface.name}" title="Настроить" style="margin-left:auto">${ICON.pencil}</button>
|
||||
</div>
|
||||
${ipv6lines || `<div class="info-row"><span class="info-label">IPv6</span><span class="info-val none">—</span></div>`}
|
||||
<div class="info-row">
|
||||
<span class="info-label">Шлюз</span>
|
||||
<span class="info-val">${iface.gateway || '<span class="none">—</span>'}</span>
|
||||
</div>
|
||||
<div class="traffic-row">
|
||||
<div class="traffic-item">
|
||||
<span class="traffic-label">↓ RX</span>
|
||||
<span class="traffic-val">${fmtBytes(iface.rx_bytes)}</span>
|
||||
</div>
|
||||
<div class="traffic-item">
|
||||
<span class="traffic-label">↑ TX</span>
|
||||
<span class="traffic-val">${fmtBytes(iface.tx_bytes)}</span>
|
||||
</div>
|
||||
<div class="traffic-item">
|
||||
<span class="traffic-label">Пакеты</span>
|
||||
<span class="traffic-val">${iface.rx_packets ?? 0} / ${iface.tx_packets ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
${!isLo ? `
|
||||
<label class="toggle-label iface-power-toggle" title="${isUp ? 'Выключить интерфейс' : 'Включить интерфейс'}">
|
||||
<input type="checkbox" ${isUp ? 'checked' : ''} data-action="toggle" data-iface="${iface.name}">
|
||||
<span class="toggle-slider"></span>
|
||||
<span class="iface-toggle-label">${isUp ? 'Вкл' : 'Выкл'}</span>
|
||||
</label>
|
||||
<button class="btn-icon" data-action="restart" data-iface="${iface.name}" title="Перезапустить">${ICON.restart}</button>
|
||||
` : ''}
|
||||
<button class="btn-icon btn-icon-accent" data-action="config" data-iface="${iface.name}" title="Настроить" style="margin-left:auto">${ICON.pencil}</button>
|
||||
</div>
|
||||
|
||||
${!isLo ? buildVLANSection(iface.name, vlans) : ''}
|
||||
</td>
|
||||
`;
|
||||
|
||||
return card;
|
||||
const frag = document.createDocumentFragment();
|
||||
frag.appendChild(tr);
|
||||
|
||||
for (const v of vlans) {
|
||||
frag.appendChild(buildVLANRow(v, iface.name));
|
||||
}
|
||||
|
||||
|
||||
return frag;
|
||||
}
|
||||
|
||||
function buildVLANSection(parentName, vlans) {
|
||||
const rows = vlans.map(v => {
|
||||
const sc = stateClass(v.state);
|
||||
const hasPending = state.pending.includes(v.name);
|
||||
const isUp = v.state === 'up';
|
||||
const label = v.label || '';
|
||||
function buildVLANRow(v, parentName) {
|
||||
const sc = stateClass(v.state);
|
||||
const hasPending = state.pending.includes(v.name);
|
||||
const isUp = v.state === 'up';
|
||||
const label = v.label || '';
|
||||
const isWAN = v.type === 'wan';
|
||||
const ip = v.ipv4
|
||||
? v.ipv4 + (v.ipv4_mask ? ' / ' + v.ipv4_mask : '')
|
||||
? v.ipv4 + (v.ipv4_mask ? maskToCIDR(v.ipv4_mask) : '')
|
||||
: '<span class="none">—</span>';
|
||||
return `
|
||||
<div class="vlan-row" data-name="${v.name}">
|
||||
<div class="vlan-row-left">
|
||||
<span class="state-dot ${sc}" style="width:8px;height:8px"></span>
|
||||
${label
|
||||
? `<div class="vlan-name-stack"><span class="vlan-label-text">${label}</span><span class="vlan-iface-name">${v.name}</span></div>`
|
||||
: `<span class="vlan-iface-name">${v.name}</span>`}
|
||||
<span class="vlan-id-tag">VLAN ${vlanId(v.name)}</span>
|
||||
<span class="mode-badge ${v.mode || 'unknown'}">${modeLabel(v.mode)}</span>
|
||||
${hasPending ? '<span class="pending-badge">несохранено</span>' : ''}
|
||||
</div>
|
||||
<div class="vlan-row-info">${ip}</div>
|
||||
<div class="vlan-row-actions">
|
||||
<label class="toggle-label" title="${isUp ? 'Выключить' : 'Включить'}">
|
||||
<input type="checkbox" ${isUp ? 'checked' : ''} data-action="toggle" data-iface="${v.name}">
|
||||
<span class="toggle-slider toggle-sm"></span>
|
||||
</label>
|
||||
<button class="btn-icon" data-action="config" data-iface="${v.name}" title="Настроить">${ICON.pencil}</button>
|
||||
<button class="btn-icon btn-icon-danger" data-action="delete" data-iface="${v.name}" title="Удалить VLAN">${ICON.trash}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const gwDisplay = v.gateway ? v.gateway : '<span class="none">—</span>';
|
||||
const typeBadge = isWAN
|
||||
? '<span class="type-badge type-wan">WAN</span>'
|
||||
: '<span class="type-badge type-lan">LAN</span>';
|
||||
|
||||
const empty = vlans.length === 0
|
||||
? `<div class="vlan-empty">Нет тегированных VLAN</div>`
|
||||
: '';
|
||||
const nameCell = label
|
||||
? `<div class="iface-name-stack"><span class="iface-label-text">${label}</span><span class="iface-name-sub">${v.name}</span></div>`
|
||||
: `<span class="iface-name-text">${v.name}</span>`;
|
||||
|
||||
return `
|
||||
<div class="vlan-section">
|
||||
<div class="vlan-header">
|
||||
<span class="vlan-title">VLAN</span>
|
||||
<button class="btn btn-ghost btn-xs" data-action="addvlan" data-iface="${parentName}">${ICON.plus} Добавить</button>
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'iface-row iface-row-vlan' + (hasPending ? ' has-pending' : '');
|
||||
tr.dataset.name = v.name;
|
||||
tr.dataset.parent = parentName;
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="col-if-state"><span class="state-dot ${sc}" style="width:8px;height:8px"></span></td>
|
||||
<td class="col-if-name">
|
||||
<div class="vlan-nested-name">
|
||||
<span class="vlan-tree-line"></span>
|
||||
<span class="vlan-id-tag">VLAN ${vlanId(v.name)}</span>
|
||||
${nameCell}
|
||||
${hasPending ? '<span class="pending-badge">несохранено</span>' : ''}
|
||||
</div>
|
||||
<div class="vlan-list">
|
||||
${rows}
|
||||
${empty}
|
||||
</td>
|
||||
<td class="col-if-type">${typeBadge}</td>
|
||||
<td class="col-if-ipv4 mono">${ip}</td>
|
||||
<td class="col-if-gw mono">${gwDisplay}</td>
|
||||
<td class="col-if-mode"><span class="mode-badge ${v.mode || 'unknown'}">${modeLabel(v.mode)}</span></td>
|
||||
<td class="col-if-traffic"></td>
|
||||
<td class="col-if-actions">
|
||||
<div class="iface-actions">
|
||||
<label class="toggle-label" title="${isUp ? 'Выключить' : 'Включить'}">
|
||||
<input type="checkbox" ${isUp ? 'checked' : ''} data-action="toggle" data-iface="${v.name}">
|
||||
<span class="toggle-slider toggle-sm"></span>
|
||||
</label>
|
||||
<button class="btn-icon btn-icon-accent" data-action="config" data-iface="${v.name}" title="Настроить">${ICON.pencil}</button>
|
||||
<button class="btn-icon btn-icon-danger" data-action="delete" data-iface="${v.name}" title="Удалить VLAN">${ICON.trash}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
</td>
|
||||
`;
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
function renderPendingBanner() {
|
||||
@@ -252,8 +295,6 @@ function renderPendingBanner() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
const [ifaces, pending] = await Promise.all([
|
||||
@@ -268,8 +309,6 @@ async function loadAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Interface actions ─────────────────────────────────────────────────────────
|
||||
|
||||
async function doAction(name, action) {
|
||||
if (action === 'delete') {
|
||||
if (!confirm(`Удалить VLAN ${name}?`)) return;
|
||||
@@ -289,18 +328,15 @@ async function doAction(name, action) {
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
showToast(`${name} ${action}: ${e.message}`, 'error');
|
||||
await loadAll(); // refresh to restore correct toggle state
|
||||
await loadAll();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config modal ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function openConfig(name) {
|
||||
state.configModal = name;
|
||||
state.configModalParent = null;
|
||||
document.getElementById('modalTitle').textContent = `Настройка: ${name}`;
|
||||
|
||||
// Show/hide VLAN ID field
|
||||
const vlanSection = document.getElementById('vlanIdSection');
|
||||
const vlanInput = document.getElementById('cfgVLANId');
|
||||
if (isVLAN(name)) {
|
||||
@@ -318,7 +354,9 @@ async function openConfig(name) {
|
||||
get('/api/nat').catch(() => null),
|
||||
]);
|
||||
if (natData) state.nat = natData;
|
||||
fillForm(configData.config, configData.pending, name, configData.label || '');
|
||||
let currentType = configData.type || 'lan';
|
||||
if (isVLAN(name)) currentType = 'lan';
|
||||
fillForm(configData.config, configData.pending, name, configData.label || '', currentType, isVLAN(name));
|
||||
document.getElementById('modal').classList.remove('hidden');
|
||||
} catch (e) {
|
||||
showToast('Ошибка загрузки конфига: ' + e.message, 'error');
|
||||
@@ -326,6 +364,11 @@ async function openConfig(name) {
|
||||
}
|
||||
|
||||
async function openNewVLAN(parentName) {
|
||||
const parentIface = state.interfaces.find(i => i.name === parentName);
|
||||
if (parentIface && parentIface.type === 'wan') {
|
||||
showToast('Нельзя добавить VLAN на WAN-интерфейс', 'error');
|
||||
return;
|
||||
}
|
||||
state.configModal = null;
|
||||
state.configModalParent = parentName;
|
||||
document.getElementById('modalTitle').textContent = `Новый VLAN на ${parentName}`;
|
||||
@@ -341,11 +384,11 @@ async function openNewVLAN(parentName) {
|
||||
if (natData) state.nat = natData;
|
||||
} catch (_) {}
|
||||
|
||||
fillForm({ auto: true, mode: 'static' }, false, '', '');
|
||||
fillForm({ auto: true, mode: 'static' }, false, '', '', 'lan', true);
|
||||
document.getElementById('modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function fillForm(cfg, pending, name, label = '') {
|
||||
function fillForm(cfg, pending, name, label = '', ifaceType = 'lan', forceLAN = false) {
|
||||
document.getElementById('cfgLabel').value = label;
|
||||
document.getElementById('cfgAuto').checked = !!cfg.auto;
|
||||
document.getElementById('cfgAddress').value = cfg.address || '';
|
||||
@@ -353,22 +396,56 @@ function fillForm(cfg, pending, name, label = '') {
|
||||
document.getElementById('cfgGateway').value = cfg.gateway || '';
|
||||
document.getElementById('cfgDNS').value = (cfg.dns || []).join(' ');
|
||||
|
||||
setType(ifaceType, forceLAN);
|
||||
|
||||
const mode = cfg.mode === 'static' ? 'static' : 'dhcp';
|
||||
setMode(mode);
|
||||
|
||||
if (pending && name) {
|
||||
document.getElementById('modalTitle').textContent = `Настройка: ${name} (несохранённые изменения)`;
|
||||
}
|
||||
}
|
||||
|
||||
// NAT section — show for all non-loopback interfaces
|
||||
function setType(t, forceLAN = false) {
|
||||
document.querySelectorAll('#typeSwitch .seg-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.type === t);
|
||||
});
|
||||
const typeRow = document.getElementById('typeSwitch').closest('.form-row');
|
||||
typeRow.classList.toggle('hidden', forceLAN);
|
||||
updateTypeVisibility(t);
|
||||
}
|
||||
|
||||
function currentType() {
|
||||
return document.querySelector('#typeSwitch .seg-btn.active')?.dataset.type ?? 'lan';
|
||||
}
|
||||
|
||||
function updateTypeVisibility(type) {
|
||||
const modeRow = document.getElementById('modeRow');
|
||||
const gatewayRow = document.getElementById('gatewayRow');
|
||||
const dnsRow = document.getElementById('dnsRow');
|
||||
const natSection = document.getElementById('natSection');
|
||||
const natNotInstalled = document.getElementById('natNotInstalled');
|
||||
const cfgNAT = document.getElementById('cfgNAT');
|
||||
|
||||
if (cfg.mode === 'loopback' || name === 'lo') {
|
||||
natSection.classList.add('hidden');
|
||||
} else {
|
||||
if (type === 'lan') {
|
||||
modeRow.classList.add('hidden');
|
||||
setMode('static');
|
||||
gatewayRow.classList.add('hidden');
|
||||
dnsRow.classList.add('hidden');
|
||||
natSection.classList.remove('hidden');
|
||||
} else {
|
||||
modeRow.classList.remove('hidden');
|
||||
gatewayRow.classList.remove('hidden');
|
||||
dnsRow.classList.remove('hidden');
|
||||
natSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
updateNATSection(type);
|
||||
}
|
||||
|
||||
function updateNATSection(type) {
|
||||
if (type === 'lan') {
|
||||
const natNotInstalled = document.getElementById('natNotInstalled');
|
||||
const cfgNAT = document.getElementById('cfgNAT');
|
||||
const name = state.configModal;
|
||||
const natInstalled = state.nat?.installed !== false;
|
||||
cfgNAT.disabled = !natInstalled;
|
||||
natNotInstalled.classList.toggle('hidden', natInstalled);
|
||||
@@ -377,14 +454,14 @@ function fillForm(cfg, pending, name, label = '') {
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
document.querySelectorAll('.seg-btn').forEach(b => {
|
||||
document.querySelectorAll('#modeSwitch .seg-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.mode === mode);
|
||||
});
|
||||
document.getElementById('staticFields').classList.toggle('hidden', mode !== 'static');
|
||||
}
|
||||
|
||||
function currentMode() {
|
||||
return document.querySelector('.seg-btn.active')?.dataset.mode ?? 'dhcp';
|
||||
return document.querySelector('#modeSwitch .seg-btn.active')?.dataset.mode ?? 'dhcp';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
@@ -398,7 +475,6 @@ async function saveConfig() {
|
||||
let name = state.configModal;
|
||||
|
||||
if (!name) {
|
||||
// New VLAN — build name from parent + VLAN ID
|
||||
const parent = state.configModalParent;
|
||||
const id = parseInt(document.getElementById('cfgVLANId').value);
|
||||
if (!parent) return;
|
||||
@@ -408,23 +484,24 @@ async function saveConfig() {
|
||||
}
|
||||
name = `${parent}.${id}`;
|
||||
|
||||
// Check for duplicate
|
||||
if (state.interfaces.find(i => i.name === name)) {
|
||||
showToast(`VLAN ${name} уже существует`, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const mode = currentMode();
|
||||
const type = currentType();
|
||||
const mode = type === 'lan' ? 'static' : currentMode();
|
||||
const cfg = {
|
||||
name,
|
||||
label: document.getElementById('cfgLabel').value.trim(),
|
||||
type,
|
||||
auto: document.getElementById('cfgAuto').checked,
|
||||
mode,
|
||||
address: document.getElementById('cfgAddress').value.trim(),
|
||||
netmask: document.getElementById('cfgNetmask').value.trim(),
|
||||
gateway: document.getElementById('cfgGateway').value.trim(),
|
||||
dns: document.getElementById('cfgDNS').value.trim().split(/\s+/).filter(Boolean),
|
||||
gateway: type === 'wan' ? document.getElementById('cfgGateway').value.trim() : '',
|
||||
dns: type === 'wan' ? document.getElementById('cfgDNS').value.trim().split(/\s+/).filter(Boolean) : [],
|
||||
extra: {},
|
||||
};
|
||||
|
||||
@@ -433,10 +510,14 @@ async function saveConfig() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'wan' && mode === 'static' && !cfg.netmask) {
|
||||
showToast('Укажите маску сети', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await post(`/api/config/${name}`, cfg);
|
||||
|
||||
// Save NAT setting if section is visible
|
||||
const natSection = document.getElementById('natSection');
|
||||
if (!natSection.classList.contains('hidden') && state.nat?.installed !== false) {
|
||||
const natEnabled = document.getElementById('cfgNAT').checked;
|
||||
@@ -456,8 +537,6 @@ async function saveConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply / discard ───────────────────────────────────────────────────────────
|
||||
|
||||
async function applyAll() {
|
||||
const btn = document.getElementById('applyBtn');
|
||||
btn.disabled = true;
|
||||
@@ -484,8 +563,6 @@ async function discardAll() {
|
||||
await loadAll();
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let toastTimer;
|
||||
function showToast(msg, type = 'info') {
|
||||
const t = document.getElementById('toast');
|
||||
@@ -496,17 +573,12 @@ function showToast(msg, type = 'info') {
|
||||
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
|
||||
}
|
||||
|
||||
// ── Event wiring ──────────────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', loadAll);
|
||||
document.getElementById('applyBtn').addEventListener('click', applyAll);
|
||||
document.getElementById('discardAllBtn').addEventListener('click', discardAll);
|
||||
|
||||
// Card button clicks (delegated)
|
||||
document.getElementById('ifaceGrid').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
// Don't handle toggle inputs here (handled by 'change' below)
|
||||
if (btn.tagName === 'INPUT' && btn.type === 'checkbox') return;
|
||||
const { action, iface } = btn.dataset;
|
||||
if (!action || !iface) return;
|
||||
@@ -519,14 +591,12 @@ document.getElementById('ifaceGrid').addEventListener('click', e => {
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle switch (on/off) — delegated change event
|
||||
document.getElementById('ifaceGrid').addEventListener('change', e => {
|
||||
const input = e.target.closest('input[data-action="toggle"]');
|
||||
if (!input) return;
|
||||
doAction(input.dataset.iface, input.checked ? 'up' : 'down');
|
||||
});
|
||||
|
||||
// Modal close
|
||||
document.getElementById('closeModal').addEventListener('click', closeModal);
|
||||
document.getElementById('cancelConfigBtn').addEventListener('click', closeModal);
|
||||
document.getElementById('modalBackdrop').addEventListener('click', closeModal);
|
||||
@@ -534,24 +604,33 @@ document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
|
||||
// Mode switcher
|
||||
document.getElementById('typeSwitch').addEventListener('click', e => {
|
||||
const btn = e.target.closest('.seg-btn');
|
||||
if (btn) setType(btn.dataset.type);
|
||||
});
|
||||
|
||||
document.getElementById('modeSwitch').addEventListener('click', e => {
|
||||
const btn = e.target.closest('.seg-btn');
|
||||
if (btn) setMode(btn.dataset.mode);
|
||||
});
|
||||
|
||||
// Save config
|
||||
document.getElementById('cfgNetmask').addEventListener('focus', () => {
|
||||
const maskInput = document.getElementById('cfgNetmask');
|
||||
if (maskInput.value) return;
|
||||
const addr = document.getElementById('cfgAddress').value.trim();
|
||||
if (!addr) return;
|
||||
const m = guessMask(addr);
|
||||
if (m) maskInput.value = m;
|
||||
});
|
||||
|
||||
document.getElementById('saveConfigBtn').addEventListener('click', saveConfig);
|
||||
document.getElementById('configForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
saveConfig();
|
||||
});
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
setInterval(loadAll, 10000);
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
(async () => {
|
||||
await loadAll();
|
||||
})();
|
||||
})();
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Клиенты — AlpineRouter</title>
|
||||
<title>Клиенты — NanoRouter</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -15,21 +15,21 @@
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<h1>AlpineRouter</h1>
|
||||
<h1>NanoRouter</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="btn btn-ghost" id="refreshBtn" title="Обновить">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
</svg>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tab-nav">
|
||||
<a href="/" class="tab-link">
|
||||
<a href="/home.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
Главная
|
||||
</a>
|
||||
<a href="/ifaces.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
@@ -64,9 +64,41 @@
|
||||
</svg>
|
||||
Прокси
|
||||
</a>
|
||||
<a href="/profile.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
Профиль
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<main class="clients-main">
|
||||
<!-- Policy management panel -->
|
||||
<div class="policy-panel">
|
||||
<div class="policy-section">
|
||||
<div class="policy-section-label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
Политика для новых устройств
|
||||
</div>
|
||||
<div class="segmented" id="defaultPolicySelector">
|
||||
<button type="button" class="seg-btn" data-val="disabled">Отключён</button>
|
||||
<button type="button" class="seg-btn" data-val="direct">Напрямую</button>
|
||||
<button type="button" class="seg-btn active" data-val="vpn">Через VPN</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="policy-divider"></div>
|
||||
<div class="policy-section">
|
||||
<div class="policy-section-label">Применить ко всем</div>
|
||||
<div class="policy-apply-all">
|
||||
<button type="button" class="btn btn-ghost btn-sm policy-all-btn" data-val="disabled">Отключить всех</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm policy-all-btn" data-val="direct">Напрямую</button>
|
||||
<button type="button" class="btn btn-primary btn-sm policy-all-btn" data-val="vpn">Через VPN</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clients-toolbar">
|
||||
<div class="clients-summary" id="clientsSummary"></div>
|
||||
@@ -92,6 +124,7 @@
|
||||
<th>MAC-адрес</th>
|
||||
<th>Интерфейс</th>
|
||||
<th>Тип</th>
|
||||
<th>Маршрут</th>
|
||||
<th class="col-tx">↑ Отправлено</th>
|
||||
<th class="col-rx">↓ Получено</th>
|
||||
<th>Активность</th>
|
||||
@@ -143,15 +176,14 @@
|
||||
|
||||
<hr class="form-divider">
|
||||
|
||||
<div class="form-row" style="flex-direction:row; align-items:center; justify-content:space-between;">
|
||||
<div>
|
||||
<div style="font-weight:600;">Доступ в интернет</div>
|
||||
<div style="font-size:.8rem;color:var(--muted);margin-top:2px;" id="modalBlockHint">Отключите, чтобы запретить устройству выход в интернет</div>
|
||||
<div class="form-row form-row--col">
|
||||
<label>Выход в интернет</label>
|
||||
<div class="segmented" id="modalPolicySelector">
|
||||
<button type="button" class="seg-btn" data-val="disabled">Отключён</button>
|
||||
<button type="button" class="seg-btn" data-val="direct">Напрямую</button>
|
||||
<button type="button" class="seg-btn" data-val="vpn">Через VPN</button>
|
||||
</div>
|
||||
<label class="toggle-label" id="modalBlockToggle">
|
||||
<input type="checkbox" id="modalBlocked">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span class="form-hint" id="modalPolicyHint">Пусто = использовать политику по умолчанию</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" style="padding:18px 0 0;">
|
||||
|
||||
@@ -3,6 +3,72 @@
|
||||
let allClients = [];
|
||||
let searchQuery = '';
|
||||
let editingClient = null;
|
||||
let modalSelectedPolicy = ''; // "" means "use default"
|
||||
|
||||
// ── Default policy ─────────────────────────────────────────────────────────
|
||||
|
||||
async function loadDefaultPolicy() {
|
||||
try {
|
||||
const res = await fetch('/api/clients/policy');
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) return;
|
||||
setDefaultPolicyUI(json.data.default || 'direct');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function setDefaultPolicyUI(val) {
|
||||
document.querySelectorAll('#defaultPolicySelector .seg-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.val === val);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveDefaultPolicy(val) {
|
||||
try {
|
||||
const res = await fetch('/api/clients/policy', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ default: val }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
setDefaultPolicyUI(val);
|
||||
showToast('Политика по умолчанию сохранена', 'success');
|
||||
loadClients();
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('#defaultPolicySelector .seg-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => saveDefaultPolicy(btn.dataset.val));
|
||||
});
|
||||
|
||||
// ── Apply-all ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function applyPolicyToAll(val) {
|
||||
const labels = { disabled: 'Отключён', direct: 'Напрямую', vpn: 'Через VPN' };
|
||||
if (!confirm(`Применить политику «${labels[val]}» ко всем устройствам?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/clients/policy/apply-all', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ policy: val }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
showToast(`Политика применена к ${json.data.updated} устройствам`, 'success');
|
||||
loadClients();
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.policy-all-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => applyPolicyToAll(btn.dataset.val));
|
||||
});
|
||||
|
||||
// ── Clients list ───────────────────────────────────────────────────────────
|
||||
|
||||
async function loadClients() {
|
||||
try {
|
||||
@@ -75,6 +141,15 @@ function matchesSearch(c, q) {
|
||||
);
|
||||
}
|
||||
|
||||
function policyLabel(policy) {
|
||||
switch (policy) {
|
||||
case 'disabled': return { text: 'Отключён', cls: 'policy-badge policy-disabled' };
|
||||
case 'vpn': return { text: 'VPN', cls: 'policy-badge policy-vpn' };
|
||||
case 'direct': return { text: 'Напрямую', cls: 'policy-badge policy-direct' };
|
||||
default: return { text: 'по умолч.', cls: 'policy-badge policy-default' };
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const loading = document.getElementById('loading');
|
||||
const wrap = document.getElementById('clientsTableWrap');
|
||||
@@ -84,15 +159,17 @@ function render() {
|
||||
|
||||
loading.classList.add('hidden');
|
||||
|
||||
const onlineCount = allClients.filter(c => isOnline(c)).length;
|
||||
const dhcpCount = allClients.filter(c => c.is_dhcp).length;
|
||||
const blockedCount = allClients.filter(c => c.blocked).length;
|
||||
const onlineCount = allClients.filter(c => isOnline(c)).length;
|
||||
const dhcpCount = allClients.filter(c => c.is_dhcp).length;
|
||||
const disabledCount = allClients.filter(c => c.policy === 'disabled' || (c.policy === '' && c.blocked)).length;
|
||||
const vpnCount = allClients.filter(c => c.policy === 'vpn').length;
|
||||
|
||||
summary.innerHTML =
|
||||
`<span class="cl-stat"><span class="state-dot up"></span>${onlineCount} онлайн</span>` +
|
||||
`<span class="cl-stat cl-stat--muted">${allClients.length} всего</span>` +
|
||||
`<span class="cl-stat cl-stat--muted">${dhcpCount} по DHCP</span>` +
|
||||
(blockedCount > 0 ? `<span class="cl-stat cl-stat--blocked">${blockedCount} заблокировано</span>` : '');
|
||||
(vpnCount > 0 ? `<span class="cl-stat cl-stat--vpn">${vpnCount} через VPN</span>` : '') +
|
||||
(disabledCount > 0 ? `<span class="cl-stat cl-stat--blocked">${disabledCount} отключено</span>` : '');
|
||||
|
||||
const filtered = allClients.filter(c => matchesSearch(c, searchQuery));
|
||||
|
||||
@@ -123,9 +200,13 @@ function buildRow(c) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'client-row';
|
||||
if (!online) tr.classList.add('row-offline');
|
||||
if (c.blocked) tr.classList.add('row-blocked');
|
||||
|
||||
const effectivePolicy = c.policy || (c.blocked ? 'disabled' : '');
|
||||
if (effectivePolicy === 'disabled') tr.classList.add('row-blocked');
|
||||
if (effectivePolicy === 'vpn') tr.classList.add('row-vpn');
|
||||
|
||||
const activity = fmtLastActive(c);
|
||||
const pl = policyLabel(effectivePolicy);
|
||||
|
||||
const typeCell = c.is_dhcp
|
||||
? '<span class="client-badge dhcp">DHCP</span>'
|
||||
@@ -151,20 +232,17 @@ function buildRow(c) {
|
||||
? `<span class="mono">${escHtml(c.static_ip)}</span> <span class="client-badge static-badge">фикс.</span>`
|
||||
: `<span class="mono">${escHtml(c.ip)}</span>`;
|
||||
|
||||
const blockedBadge = c.blocked
|
||||
? ' <span class="client-badge blocked-badge">ЗАБЛОКИРОВАН</span>'
|
||||
: '';
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="col-status">
|
||||
<span class="state-dot ${online ? 'up' : 'down'}"
|
||||
title="${online ? 'онлайн' : 'офлайн'}"></span>
|
||||
</td>
|
||||
<td class="col-host">${hostname}${blockedBadge}</td>
|
||||
<td class="col-host">${hostname}</td>
|
||||
<td class="col-ip">${ipDisplay}</td>
|
||||
<td class="col-mac"><span class="mono muted">${escHtml(c.mac || '—')}</span></td>
|
||||
<td class="col-iface">${escHtml(c.interface || '—')}</td>
|
||||
<td class="col-type">${typeCell}</td>
|
||||
<td class="col-policy"><span class="${pl.cls}">${pl.text}</span></td>
|
||||
<td class="col-tx">${txHtml}</td>
|
||||
<td class="col-rx">${rxHtml}</td>
|
||||
<td class="col-activity">${actHtml}</td>
|
||||
@@ -174,6 +252,8 @@ function buildRow(c) {
|
||||
return tr;
|
||||
}
|
||||
|
||||
// ── Modal ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function openModal(c) {
|
||||
editingClient = { ...c };
|
||||
const modal = document.getElementById('clientModal');
|
||||
@@ -200,32 +280,36 @@ function openModal(c) {
|
||||
document.getElementById('modalMAC').textContent = c.mac || '—';
|
||||
document.getElementById('modalIface').textContent = c.interface || '—';
|
||||
|
||||
const blocked = document.getElementById('modalBlocked');
|
||||
blocked.checked = !c.blocked;
|
||||
updateBlockedToggle(c.blocked);
|
||||
// Set policy selector — empty string means "use default"
|
||||
const effectivePolicy = c.policy || '';
|
||||
modalSelectedPolicy = effectivePolicy;
|
||||
updateModalPolicySelector(effectivePolicy);
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
document.getElementById('modalHostname').focus();
|
||||
}
|
||||
|
||||
function updateBlockedToggle(isBlocked) {
|
||||
const hint = document.getElementById('modalBlockHint');
|
||||
const toggle = document.getElementById('modalBlockToggle');
|
||||
const toggleContainer = document.getElementById('modalBlocked');
|
||||
|
||||
if (isBlocked) {
|
||||
hint.textContent = 'Доступ в интернет заблокирован';
|
||||
hint.style.color = 'var(--danger)';
|
||||
toggleContainer.checked = false;
|
||||
toggle.classList.add('toggle-blocked');
|
||||
} else {
|
||||
hint.textContent = 'Отключите, чтобы запретить устройству выход в интернет';
|
||||
hint.style.color = '';
|
||||
toggleContainer.checked = true;
|
||||
toggle.classList.remove('toggle-blocked');
|
||||
}
|
||||
function updateModalPolicySelector(val) {
|
||||
document.querySelectorAll('#modalPolicySelector .seg-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.val === val);
|
||||
});
|
||||
const hint = document.getElementById('modalPolicyHint');
|
||||
const descriptions = {
|
||||
disabled: 'Устройство заблокировано — нет доступа в интернет',
|
||||
direct: 'Трафик идёт напрямую через NAT, минуя VPN',
|
||||
vpn: 'Трафик перенаправляется через Mihomo (tproxy)',
|
||||
'': 'Используется политика по умолчанию',
|
||||
};
|
||||
hint.textContent = descriptions[val] ?? '';
|
||||
}
|
||||
|
||||
document.querySelectorAll('#modalPolicySelector .seg-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
modalSelectedPolicy = btn.dataset.val;
|
||||
updateModalPolicySelector(btn.dataset.val);
|
||||
});
|
||||
});
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('clientModal').classList.add('hidden');
|
||||
editingClient = null;
|
||||
@@ -241,8 +325,8 @@ async function saveClient() {
|
||||
}
|
||||
|
||||
const hostname = document.getElementById('modalHostname').value.trim();
|
||||
const isBlocked = !document.getElementById('modalBlocked').checked;
|
||||
const staticIP = document.getElementById('modalStaticIP').value.trim();
|
||||
const policy = modalSelectedPolicy; // "disabled" | "direct" | "vpn" | ""
|
||||
|
||||
const btn = document.getElementById('modalSave');
|
||||
btn.disabled = true;
|
||||
@@ -251,7 +335,12 @@ async function saveClient() {
|
||||
const res = await fetch('/api/clients/update/' + encodeURIComponent(mac), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hostname, blocked: isBlocked, static_ip: staticIP })
|
||||
body: JSON.stringify({
|
||||
hostname,
|
||||
blocked: policy === 'disabled',
|
||||
static_ip: staticIP,
|
||||
policy,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
@@ -265,6 +354,8 @@ async function saveClient() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utilities ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ipToNum(ip) {
|
||||
return (ip || '').split('.').reduce((acc, o) => acc * 256 + (+o || 0), 0);
|
||||
}
|
||||
@@ -284,7 +375,8 @@ function showToast(msg, type = 'info') {
|
||||
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
|
||||
}
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', loadClients);
|
||||
// ── Event wiring ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
document.getElementById('clientsSearch').addEventListener('input', e => {
|
||||
searchQuery = e.target.value.trim();
|
||||
@@ -299,16 +391,11 @@ document.getElementById('clientForm').addEventListener('submit', e => {
|
||||
saveClient();
|
||||
});
|
||||
|
||||
document.getElementById('modalBlocked').addEventListener('change', () => {
|
||||
if (!editingClient) return;
|
||||
const isBlocked = !document.getElementById('modalBlocked').checked;
|
||||
updateBlockedToggle(isBlocked);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
|
||||
setInterval(loadClients, 10000);
|
||||
|
||||
loadClients();
|
||||
loadDefaultPolicy();
|
||||
loadClients();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DHCP сервер — AlpineRouter</title>
|
||||
<title>DHCP сервер — NanoRouter</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -15,21 +15,21 @@
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<h1>AlpineRouter</h1>
|
||||
<h1>NanoRouter</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="btn btn-ghost" id="refreshBtn" title="Обновить">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
</svg>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tab-nav">
|
||||
<a href="/" class="tab-link">
|
||||
<a href="/home.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
Главная
|
||||
</a>
|
||||
<a href="/ifaces.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
@@ -64,10 +64,16 @@
|
||||
</svg>
|
||||
Прокси
|
||||
</a>
|
||||
<a href="/profile.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
Профиль
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<main class="dhcp-main">
|
||||
|
||||
<!-- Not-installed warning -->
|
||||
<div id="notInstalledBanner" class="alert alert-error hidden">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
||||
|
||||
@@ -296,7 +296,6 @@ function showToast(msg, type = 'info') {
|
||||
|
||||
// ── Event wiring ──────────────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', loadAll);
|
||||
|
||||
document.getElementById('enableToggle').addEventListener('change', e => {
|
||||
state.config.enabled = e.target.checked;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Файрвол — AlpineRouter</title>
|
||||
<title>Файрвол — NanoRouter</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -15,21 +15,21 @@
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<h1>AlpineRouter</h1>
|
||||
<h1>NanoRouter</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="btn btn-ghost" id="refreshBtn" title="Обновить">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
</svg>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tab-nav">
|
||||
<a href="/" class="tab-link">
|
||||
<a href="/home.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
Главная
|
||||
</a>
|
||||
<a href="/ifaces.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
@@ -64,10 +64,16 @@
|
||||
</svg>
|
||||
Прокси
|
||||
</a>
|
||||
<a href="/profile.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
Профиль
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<main class="fw-main">
|
||||
|
||||
<div id="notInstalledBanner" class="alert alert-error hidden">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/>
|
||||
|
||||
@@ -279,7 +279,6 @@ function showToast(msg, type = 'info') {
|
||||
|
||||
// ── Event wiring ──────────────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', loadAll);
|
||||
document.getElementById('applyBtn').addEventListener('click', saveAndApply);
|
||||
document.getElementById('addRuleBtn').addEventListener('click', () => openModal(-1));
|
||||
|
||||
|
||||
291
public/home.html
Normal file
291
public/home.html
Normal file
@@ -0,0 +1,291 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NanoRouter — Главная</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<h1>NanoRouter</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tab-nav">
|
||||
<a href="/home.html" class="tab-link active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
Главная
|
||||
</a>
|
||||
<a href="/ifaces.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
Интерфейсы
|
||||
</a>
|
||||
<a href="/dhcp.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
DHCP сервер
|
||||
</a>
|
||||
<a href="/clients.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
</svg>
|
||||
Клиенты
|
||||
</a>
|
||||
<a href="/firewall.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
<path d="M9 12l2 2 4-4"/>
|
||||
</svg>
|
||||
Файрвол
|
||||
</a>
|
||||
<a href="/proxy.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
|
||||
</svg>
|
||||
Прокси
|
||||
</a>
|
||||
<a href="/profile.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
Профиль
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<main class="home-main">
|
||||
<div id="defaultPwWarning" class="alert alert-error hidden" style="margin-bottom:16px">
|
||||
<div>
|
||||
<strong>⚠ Пароль по умолчанию!</strong> Аккаунт использует стандартный пароль <code>admin:admin</code>. <a href="/profile.html" style="color:var(--accent)">Задайте свой пароль →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
|
||||
<!-- Status Banner -->
|
||||
<div id="statusBanner" class="status-banner hidden">
|
||||
<div id="statusIcon" class="status-icon"></div>
|
||||
<div class="status-text">
|
||||
<div id="statusTitle" class="status-title"></div>
|
||||
<div id="statusSubtitle" class="status-subtitle"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Row: System + Mihomo -->
|
||||
<div class="home-grid-2">
|
||||
<!-- System Resources -->
|
||||
<div class="dash-card">
|
||||
<h3 class="dash-card-title">Система</h3>
|
||||
<div class="gauge-row">
|
||||
<div class="gauge-item">
|
||||
<div class="gauge-svg-wrap">
|
||||
<svg viewBox="0 0 100 100" class="gauge-svg">
|
||||
<circle cx="50" cy="50" r="42" class="gauge-bg"/>
|
||||
<circle cx="50" cy="50" r="42" class="gauge-fg" id="cpuArc" transform="rotate(-90 50 50)"/>
|
||||
</svg>
|
||||
<div class="gauge-val" id="cpuVal">—</div>
|
||||
</div>
|
||||
<div class="gauge-label">CPU</div>
|
||||
</div>
|
||||
<div class="gauge-item">
|
||||
<div class="gauge-svg-wrap">
|
||||
<svg viewBox="0 0 100 100" class="gauge-svg">
|
||||
<circle cx="50" cy="50" r="42" class="gauge-bg"/>
|
||||
<circle cx="50" cy="50" r="42" class="gauge-fg gauge-mem" id="memArc" transform="rotate(-90 50 50)"/>
|
||||
</svg>
|
||||
<div class="gauge-val" id="memVal">—</div>
|
||||
</div>
|
||||
<div class="gauge-label">ОЗУ</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sys-info" id="sysInfo"></div>
|
||||
</div>
|
||||
|
||||
<!-- Mihomo Status -->
|
||||
<div class="dash-card">
|
||||
<h3 class="dash-card-title">Mihomo</h3>
|
||||
<div id="mihomoStatus" class="mihomo-block">
|
||||
<div class="mihomo-info">
|
||||
<span id="mihomoBadge" class="svc-badge stopped">Остановлен</span>
|
||||
<span id="mihomoPid" class="mihomo-pid"></span>
|
||||
</div>
|
||||
<div class="mihomo-actions">
|
||||
<label class="toggle-label" title="Запустить / Остановить">
|
||||
<input type="checkbox" id="mihomoToggle">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<button class="btn-icon" id="mihomoRestartBtn" disabled title="Перезапустить">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connectivity -->
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-header-row">
|
||||
<h3 class="dash-card-title">Подключение</h3>
|
||||
<button class="btn-icon" id="connSettingsBtn" title="Настройки проверки подключения">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="conn-row">
|
||||
<div class="conn-item">
|
||||
<div class="conn-label">Интернет (напрямую)</div>
|
||||
<div class="conn-timeline" id="directTimeline"></div>
|
||||
<div class="conn-current" id="directStatus"></div>
|
||||
</div>
|
||||
<div class="conn-item">
|
||||
<div class="conn-label">VPN (через Mihomo)</div>
|
||||
<div class="conn-timeline" id="vpnTimeline"></div>
|
||||
<div class="conn-current" id="vpnStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Traffic Speed -->
|
||||
<div class="dash-card dash-wide">
|
||||
<div class="dash-card-header-row">
|
||||
<h3 class="dash-card-title">Загрузка сети</h3>
|
||||
<div class="dash-mode-switch" id="speedModeSwitch">
|
||||
<button class="seg-btn" data-mode="avg">10 мин</button>
|
||||
<button class="seg-btn active" data-mode="real">Realtime</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="speed-current-row">
|
||||
<div class="speed-item">
|
||||
<span class="speed-arrow">↓</span>
|
||||
<span class="speed-value" id="rxSpeed">0 bps</span>
|
||||
</div>
|
||||
<div class="speed-item">
|
||||
<span class="speed-arrow">↑</span>
|
||||
<span class="speed-value" id="txSpeed">0 bps</span>
|
||||
</div>
|
||||
<div class="speed-iface" id="gwIface"></div>
|
||||
</div>
|
||||
<div class="speed-chart-wrap">
|
||||
<canvas id="speedChart" width="800" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP Info -->
|
||||
<div class="home-grid-2">
|
||||
<div class="dash-card">
|
||||
<h3 class="dash-card-title">IP без VPN</h3>
|
||||
<div id="ipDirect" class="ip-block">
|
||||
<div class="ip-address">—</div>
|
||||
<div class="ip-country">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-card">
|
||||
<h3 class="dash-card-title">IP с VPN</h3>
|
||||
<div id="ipVPN" class="ip-block">
|
||||
<div class="ip-address">—</div>
|
||||
<div class="ip-country">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Connectivity Settings Modal -->
|
||||
<div id="connModal" class="modal hidden">
|
||||
<div class="modal-backdrop" id="connModalBackdrop"></div>
|
||||
<div class="modal-box">
|
||||
<div class="modal-header">
|
||||
<h2>Настройки проверки подключения</h2>
|
||||
<button class="btn-icon" id="closeConnModal" title="Закрыть">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="connSettingsForm">
|
||||
<div class="conn-settings-section">
|
||||
<div class="conn-settings-label">Прямое подключение</div>
|
||||
<div class="conn-endpoint-row">
|
||||
<div class="form-row">
|
||||
<label>Название 1</label>
|
||||
<input type="text" id="direct1Name" placeholder="Cloudflare">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>URL 1</label>
|
||||
<input type="text" id="direct1Url" placeholder="http://cp.cloudflare.com/generate_204">
|
||||
</div>
|
||||
</div>
|
||||
<div class="conn-endpoint-row">
|
||||
<div class="form-row">
|
||||
<label>Название 2</label>
|
||||
<input type="text" id="direct2Name" placeholder="Google">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>URL 2</label>
|
||||
<input type="text" id="direct2Url" placeholder="http://connectivitycheck.gstatic.com/generate_204">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="form-divider">
|
||||
<div class="conn-settings-section">
|
||||
<div class="conn-settings-label">Через прокси (VPN)</div>
|
||||
<div class="conn-endpoint-row">
|
||||
<div class="form-row">
|
||||
<label>Название 1</label>
|
||||
<input type="text" id="proxy1Name" placeholder="Cloudflare">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>URL 1</label>
|
||||
<input type="text" id="proxy1Url" placeholder="http://cp.cloudflare.com/generate_204">
|
||||
</div>
|
||||
</div>
|
||||
<div class="conn-endpoint-row">
|
||||
<div class="form-row">
|
||||
<label>Название 2</label>
|
||||
<input type="text" id="proxy2Name" placeholder="Google">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>URL 2</label>
|
||||
<input type="text" id="proxy2Url" placeholder="http://connectivitycheck.gstatic.com/generate_204">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="conn-settings-hint">Соединение считается успешным при HTTP-коде 2xx или 3xx.</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" id="cancelConnSettings">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
<script src="home.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
785
public/home.js
Normal file
785
public/home.js
Normal file
@@ -0,0 +1,785 @@
|
||||
'use strict';
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
let dashData = null;
|
||||
let speedMode = 'real';
|
||||
let chartSamples = [];
|
||||
let chartHoverIdx = null;
|
||||
|
||||
function fmtSpeed(bps) {
|
||||
if (bps === 0) return '0 bps';
|
||||
const units = ['bps', 'Kbps', 'Mbps', 'Gbps'];
|
||||
let v = bps;
|
||||
let i = 0;
|
||||
while (v >= 1000 && i < units.length - 1) { v /= 1000; i++; }
|
||||
if (i === 0) return Math.round(v) + ' bps';
|
||||
return v.toFixed(1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (n === undefined || n === null) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
let v = Number(n);
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function countryFlag(cc) {
|
||||
if (!cc || cc.length !== 2) return '';
|
||||
const base = 0x1F1E6;
|
||||
return String.fromCodePoint(base + cc.toUpperCase().charCodeAt(0) - 65) +
|
||||
String.fromCodePoint(base + cc.toUpperCase().charCodeAt(1) - 65);
|
||||
}
|
||||
|
||||
function setArc(id, pct, colorVar) {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
const r = 42;
|
||||
const c = 2 * Math.PI * r;
|
||||
const offset = c - (pct / 100) * c;
|
||||
el.style.strokeDasharray = c;
|
||||
el.style.strokeDashoffset = offset;
|
||||
if (colorVar) el.style.stroke = colorVar;
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: body ? { 'Content-Type': 'application/json' } : {} };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(path, opts);
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) throw new Error(json.error || 'HTTP ' + res.status);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
const get = path => api('GET', path);
|
||||
const post = (path, body) => api('POST', path, body);
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
dashData = await get('/api/dashboard');
|
||||
render();
|
||||
} catch (e) {
|
||||
console.error('dashboard load error', e);
|
||||
}
|
||||
$('loading').classList.add('hidden');
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!dashData) return;
|
||||
const d = dashData;
|
||||
|
||||
// System
|
||||
const cpuPct = d.system.cpu_pct || 0;
|
||||
const memPct = d.system.mem_pct || 0;
|
||||
setArc('cpuArc', cpuPct, cpuPct > 80 ? 'var(--danger)' : 'var(--accent)');
|
||||
setArc('memArc', memPct, memPct > 90 ? 'var(--danger)' : 'var(--success)');
|
||||
$('cpuVal').textContent = Math.round(cpuPct) + '%';
|
||||
$('memVal').textContent = Math.round(memPct) + '%';
|
||||
|
||||
const sysLines = [];
|
||||
if (d.system.mem_total) {
|
||||
sysLines.push(fmtBytes(d.system.mem_used) + ' / ' + fmtBytes(d.system.mem_total));
|
||||
}
|
||||
if (d.system.uptime) {
|
||||
const u = d.system.uptime;
|
||||
const days = Math.floor(u / 86400);
|
||||
const hrs = Math.floor((u % 86400) / 3600);
|
||||
const mins = Math.floor((u % 3600) / 60);
|
||||
sysLines.push('Uptime: ' + (days ? days + 'д ' : '') + hrs + 'ч ' + mins + 'м');
|
||||
}
|
||||
$('sysInfo').innerHTML = sysLines.map(l => '<div class="sys-line">' + l + '</div>').join('');
|
||||
|
||||
// Mihomo
|
||||
const mihomoRunning = d.mihomo.running;
|
||||
const badge = $('mihomoBadge');
|
||||
const toggle = $('mihomoToggle');
|
||||
const restartBtn = $('mihomoRestartBtn');
|
||||
const pidEl = $('mihomoPid');
|
||||
if (mihomoRunning) {
|
||||
badge.className = 'svc-badge running';
|
||||
badge.textContent = 'Запущен';
|
||||
pidEl.textContent = 'PID ' + (d.mihomo.pid || '?');
|
||||
toggle.checked = true;
|
||||
restartBtn.disabled = false;
|
||||
} else {
|
||||
badge.className = 'svc-badge stopped';
|
||||
badge.textContent = 'Остановлен';
|
||||
pidEl.textContent = '';
|
||||
toggle.checked = false;
|
||||
restartBtn.disabled = true;
|
||||
}
|
||||
|
||||
// Connectivity Status Banner
|
||||
const conn = d.connectivity;
|
||||
const banner = $('statusBanner');
|
||||
const icon = $('statusIcon');
|
||||
const title = $('statusTitle');
|
||||
const subtitle = $('statusSubtitle');
|
||||
|
||||
banner.classList.remove('hidden', 'status-online', 'status-warning', 'status-offline');
|
||||
|
||||
if (conn.direct_up) {
|
||||
banner.classList.add('status-online');
|
||||
icon.innerHTML = '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>';
|
||||
if (!conn.vpn_up && mihomoRunning) {
|
||||
banner.classList.remove('status-online');
|
||||
banner.classList.add('status-warning');
|
||||
icon.innerHTML = '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
|
||||
title.textContent = 'Интернет есть, но VPN недоступен';
|
||||
subtitle.textContent = 'Прямое подключение работает, но VPN-туннель не отвечает';
|
||||
} else {
|
||||
title.textContent = 'Мы онлайн';
|
||||
subtitle.textContent = conn.vpn_up && mihomoRunning ? 'Интернет и VPN работают' : 'Подключение к интернету активно';
|
||||
}
|
||||
} else {
|
||||
banner.classList.add('status-offline');
|
||||
icon.innerHTML = '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
title.textContent = 'Нет интернет-соединения';
|
||||
subtitle.textContent = 'Проверьте подключение к сети';
|
||||
}
|
||||
|
||||
// Connectivity timelines
|
||||
renderTimeline('directTimeline', conn.minutes_direct, conn.direct_up);
|
||||
renderTimeline('vpnTimeline', conn.minutes_vpn, conn.vpn_up);
|
||||
|
||||
// Current connectivity status markers
|
||||
$('directStatus').innerHTML = conn.direct_up
|
||||
? '<span class="conn-up">● Доступен ' + (conn.direct_up_since ? conn.direct_up_since : '') + '</span>'
|
||||
: '<span class="conn-down">● Недоступен</span>';
|
||||
$('vpnStatus').innerHTML = mihomoRunning
|
||||
? (conn.vpn_up ? '<span class="conn-up">● Доступен ' + (conn.vpn_up_since ? conn.vpn_up_since : '') + '</span>' : '<span class="conn-down">● Недоступен</span>')
|
||||
: '<span class="conn-na">— Не запущен</span>';
|
||||
|
||||
// Traffic speed
|
||||
const samples = speedMode === 'real' ? (d.traffic_real || []) : (d.traffic_avg || []);
|
||||
$('gwIface').textContent = d.gateway_iface ? ('Шлюз: ' + d.gateway_iface) : '';
|
||||
|
||||
const lastSample = samples.length > 0 ? samples[samples.length - 1] : null;
|
||||
$('rxSpeed').textContent = lastSample ? fmtSpeed(lastSample.rx_bps) : '0 bps';
|
||||
$('txSpeed').textContent = lastSample ? fmtSpeed(lastSample.tx_bps) : '0 bps';
|
||||
|
||||
drawChart(samples);
|
||||
|
||||
// IP Info
|
||||
renderIP('ipDirect', d.ip_direct);
|
||||
renderIP('ipVPN', d.ip_vpn, !mihomoRunning);
|
||||
}
|
||||
|
||||
function renderTimeline(containerId, minutes) {
|
||||
const container = $(containerId);
|
||||
if (!container) return;
|
||||
|
||||
const n = minutes.length;
|
||||
let html = '<div class="tl-bars">';
|
||||
for (let i = 0; i < n; i++) {
|
||||
const m = minutes[i];
|
||||
let cls = 'tl-bar';
|
||||
if (m.status === 'up') cls += ' tl-up';
|
||||
else if (m.status === 'down') cls += ' tl-down';
|
||||
else cls += ' tl-na';
|
||||
|
||||
const minuteTime = new Date(m.minute * 60000);
|
||||
const label = minuteTime.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
const pingsData = m.pings || {};
|
||||
const pingsJson = encodeURIComponent(JSON.stringify(pingsData));
|
||||
const dataPingCF = m.ping_cf || '';
|
||||
const dataPingGG = m.ping_gg || '';
|
||||
|
||||
html += '<div class="' + cls + '" style="width:calc(100%/' + n + ')" data-time="' + label + '" data-status="' + m.status + '" data-pings="' + pingsJson + '" data-ping-cf="' + dataPingCF + '" data-ping-gg="' + dataPingGG + '"></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
html += '<div class="tl-labels"><span>1ч назад</span><span>45м</span><span>30м</span><span>15м</span><span>Сейчас</span></div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function drawChart(samples) {
|
||||
chartSamples = samples || [];
|
||||
const canvas = $('speedChart');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
_drawChartContent(ctx, rect.width, rect.height, chartSamples, chartHoverIdx);
|
||||
}
|
||||
|
||||
// Chart inner padding — must match CHART_PAD in event handlers below
|
||||
const CHART_PAD = { top: 18, right: 20, bottom: 34, left: 72 };
|
||||
|
||||
function _drawChartContent(ctx, w, h, samples, hoverIdx) {
|
||||
const pad = CHART_PAD;
|
||||
const cw = w - pad.left - pad.right;
|
||||
const ch = h - pad.top - pad.bottom;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (!samples || samples.length < 2) {
|
||||
ctx.fillStyle = 'rgba(122, 162, 204, 0.4)';
|
||||
ctx.font = '13px Inter, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('Нет данных', w / 2, h / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
let maxVal = 1000;
|
||||
for (const s of samples) {
|
||||
if (s.rx_bps > maxVal) maxVal = s.rx_bps;
|
||||
if (s.tx_bps > maxVal) maxVal = s.tx_bps;
|
||||
}
|
||||
// Round up to a clean value
|
||||
const rawMax = maxVal * 1.2;
|
||||
const mag = Math.pow(10, Math.floor(Math.log10(rawMax)));
|
||||
maxVal = Math.ceil(rawMax / mag) * mag;
|
||||
|
||||
const timeStart = new Date(samples[0].time).getTime();
|
||||
const timeEnd = new Date(samples[samples.length - 1].time).getTime();
|
||||
const timeRange = timeEnd - timeStart || 1;
|
||||
|
||||
function xOf(s) {
|
||||
return pad.left + ((new Date(s.time).getTime() - timeStart) / timeRange) * cw;
|
||||
}
|
||||
function yOf(bps) {
|
||||
return pad.top + ch * (1 - bps / maxVal);
|
||||
}
|
||||
|
||||
// ── Y-axis grid + labels ──
|
||||
const gridLines = 5;
|
||||
ctx.font = '10px JetBrains Mono, monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const y = pad.top + (ch / gridLines) * i;
|
||||
const val = maxVal * (1 - i / gridLines);
|
||||
ctx.strokeStyle = i === gridLines ? 'rgba(0,200,255,0.18)' : 'rgba(0,200,255,0.06)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, y);
|
||||
ctx.lineTo(w - pad.right, y);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = 'rgba(122,162,204,0.9)';
|
||||
ctx.fillText(fmtSpeed(val), pad.left - 8, y);
|
||||
}
|
||||
|
||||
// ── X-axis time labels ──
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = '10px JetBrains Mono, monospace';
|
||||
ctx.fillStyle = 'rgba(122,162,204,0.6)';
|
||||
const minGap = 70;
|
||||
const pxPerSmp = samples.length > 1 ? cw / (samples.length - 1) : cw;
|
||||
const step = Math.max(1, Math.ceil(minGap / pxPerSmp));
|
||||
const shownLbl = new Set();
|
||||
for (let i = 0; i < samples.length; i += step) {
|
||||
const x = xOf(samples[i]);
|
||||
ctx.fillText(fmtTime(new Date(samples[i].time)), x, pad.top + ch + 6);
|
||||
shownLbl.add(i);
|
||||
ctx.strokeStyle = 'rgba(0,200,255,0.14)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(x, pad.top + ch); ctx.lineTo(x, pad.top + ch + 4); ctx.stroke();
|
||||
}
|
||||
// Always show last label if not too close to previous
|
||||
const lastIdx = samples.length - 1;
|
||||
if (!shownLbl.has(lastIdx)) {
|
||||
const lastX = xOf(samples[lastIdx]);
|
||||
const prevI = Math.floor(lastIdx / step) * step;
|
||||
const prevX = xOf(samples[Math.min(prevI, lastIdx)]);
|
||||
if (lastX - prevX > 44) {
|
||||
ctx.fillText(fmtTime(new Date(samples[lastIdx].time)), lastX, pad.top + ch + 6);
|
||||
ctx.strokeStyle = 'rgba(0,200,255,0.14)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(lastX, pad.top + ch); ctx.lineTo(lastX, pad.top + ch + 4); ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
const baseY = pad.top + ch;
|
||||
|
||||
// ── Cardinal-spline path helper (control points clamped to chart bounds) ──
|
||||
function smoothPath(pts) {
|
||||
if (pts.length < 2) return;
|
||||
ctx.moveTo(pts[0].x, pts[0].y);
|
||||
const t = 0.2;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[Math.max(i - 1, 0)];
|
||||
const p1 = pts[i];
|
||||
const p2 = pts[i + 1];
|
||||
const p3 = pts[Math.min(i + 2, pts.length - 1)];
|
||||
const clampY = v => Math.max(pad.top, Math.min(baseY, v));
|
||||
ctx.bezierCurveTo(
|
||||
p1.x + (p2.x - p0.x) * t, clampY(p1.y + (p2.y - p0.y) * t),
|
||||
p2.x - (p3.x - p1.x) * t, clampY(p2.y - (p3.y - p1.y) * t),
|
||||
p2.x, p2.y
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rxPts = samples.map(s => ({ x: xOf(s), y: yOf(s.rx_bps) }));
|
||||
const txPts = samples.map(s => ({ x: xOf(s), y: yOf(s.tx_bps) }));
|
||||
|
||||
// ── Clip fills + lines to plot area so bezier curves can't overflow ──
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(pad.left, pad.top, cw, ch);
|
||||
ctx.clip();
|
||||
|
||||
// ── RX fill ──
|
||||
const rxGrad = ctx.createLinearGradient(0, pad.top, 0, baseY);
|
||||
rxGrad.addColorStop(0, 'rgba(0,212,255,0.22)');
|
||||
rxGrad.addColorStop(1, 'rgba(0,212,255,0.01)');
|
||||
ctx.beginPath();
|
||||
smoothPath(rxPts);
|
||||
ctx.lineTo(rxPts[rxPts.length - 1].x, baseY);
|
||||
ctx.lineTo(rxPts[0].x, baseY);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = rxGrad;
|
||||
ctx.fill();
|
||||
|
||||
// ── RX line ──
|
||||
ctx.beginPath();
|
||||
smoothPath(rxPts);
|
||||
ctx.strokeStyle = 'rgba(0,212,255,0.95)';
|
||||
ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
// ── TX fill ──
|
||||
const txGrad = ctx.createLinearGradient(0, pad.top, 0, baseY);
|
||||
txGrad.addColorStop(0, 'rgba(0,255,136,0.16)');
|
||||
txGrad.addColorStop(1, 'rgba(0,255,136,0.01)');
|
||||
ctx.beginPath();
|
||||
smoothPath(txPts);
|
||||
ctx.lineTo(txPts[txPts.length - 1].x, baseY);
|
||||
ctx.lineTo(txPts[0].x, baseY);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = txGrad;
|
||||
ctx.fill();
|
||||
|
||||
// ── TX line ──
|
||||
ctx.beginPath();
|
||||
smoothPath(txPts);
|
||||
ctx.strokeStyle = 'rgba(0,255,136,0.9)';
|
||||
ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore(); // end clip
|
||||
|
||||
// ── Legend (top-right, clear of Y-axis labels) ──
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = '11px Inter, sans-serif';
|
||||
const ly = pad.top + 11;
|
||||
const lx = w - pad.right - 104;
|
||||
ctx.fillStyle = 'rgba(0,212,255,0.9)'; ctx.fillRect(lx, ly - 1, 14, 2);
|
||||
ctx.fillStyle = 'rgba(122,162,204,0.85)'; ctx.fillText('↓ RX', lx + 18, ly);
|
||||
ctx.fillStyle = 'rgba(0,255,136,0.9)'; ctx.fillRect(lx + 58, ly - 1, 14, 2);
|
||||
ctx.fillStyle = 'rgba(122,162,204,0.85)'; ctx.fillText('↑ TX', lx + 76, ly);
|
||||
|
||||
// ── Hover: crosshair + glowing dots ──
|
||||
if (hoverIdx !== null && hoverIdx >= 0 && hoverIdx < samples.length) {
|
||||
const s = samples[hoverIdx];
|
||||
const hx = xOf(s);
|
||||
const ry = yOf(s.rx_bps);
|
||||
const ty = yOf(s.tx_bps);
|
||||
|
||||
// Vertical dashed crosshair
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(hx, pad.top); ctx.lineTo(hx, baseY); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
|
||||
// Horizontal guide lines to Y-axis
|
||||
ctx.save();
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([2, 5]);
|
||||
ctx.strokeStyle = 'rgba(0,212,255,0.2)';
|
||||
ctx.beginPath(); ctx.moveTo(pad.left, ry); ctx.lineTo(hx, ry); ctx.stroke();
|
||||
ctx.strokeStyle = 'rgba(0,255,136,0.18)';
|
||||
ctx.beginPath(); ctx.moveTo(pad.left, ty); ctx.lineTo(hx, ty); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
|
||||
// RX dot — outer glow
|
||||
ctx.beginPath(); ctx.arc(hx, ry, 8, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0,212,255,0.15)'; ctx.fill();
|
||||
// RX dot — core
|
||||
ctx.beginPath(); ctx.arc(hx, ry, 4.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#00d4ff'; ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(10,14,26,0.9)'; ctx.lineWidth = 1.5; ctx.stroke();
|
||||
|
||||
// TX dot — outer glow
|
||||
ctx.beginPath(); ctx.arc(hx, ty, 8, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0,255,136,0.12)'; ctx.fill();
|
||||
// TX dot — core
|
||||
ctx.beginPath(); ctx.arc(hx, ty, 4.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#00ff88'; ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(10,14,26,0.9)'; ctx.lineWidth = 1.5; ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function renderIP(containerId, info, disabled) {
|
||||
const el = $(containerId);
|
||||
if (!el) return;
|
||||
|
||||
if (disabled) {
|
||||
el.innerHTML = '<div class="ip-address ip-na">Mihomo не запущен</div><div class="ip-country"></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
el.innerHTML = '<div class="ip-address ip-loading">Определение...</div><div class="ip-country"></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const flag = info.cc ? countryFlag(info.cc) + ' ' : '';
|
||||
el.innerHTML = '<div class="ip-address">' + info.ip + '</div>' +
|
||||
'<div class="ip-country">' + flag + (info.country || '') + '</div>';
|
||||
}
|
||||
|
||||
// Mihomo controls
|
||||
$('mihomoToggle').addEventListener('change', async (e) => {
|
||||
const action = e.target.checked ? 'start' : 'stop';
|
||||
try {
|
||||
await post('/api/mihomo/' + action, null);
|
||||
showToast('Mihomo ' + (action === 'start' ? 'запущен' : 'остановлен'), 'success');
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
showToast('Ошибка: ' + err.message, 'error');
|
||||
await loadData();
|
||||
}
|
||||
});
|
||||
|
||||
$('mihomoRestartBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await post('/api/mihomo/restart', null);
|
||||
showToast('Mihomo перезапущен', 'success');
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Speed mode switch
|
||||
$('speedModeSwitch').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.seg-btn');
|
||||
if (!btn) return;
|
||||
speedMode = btn.dataset.mode;
|
||||
$('speedModeSwitch').querySelectorAll('.seg-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
if (dashData) render();
|
||||
});
|
||||
|
||||
// Connectivity settings modal
|
||||
function openConnSettings() {
|
||||
const conn = dashData ? dashData.connectivity : null;
|
||||
const defaultDirect = [
|
||||
{ name: 'Cloudflare', url: 'http://cp.cloudflare.com/generate_204' },
|
||||
{ name: 'Google', url: 'http://connectivitycheck.gstatic.com/generate_204' },
|
||||
];
|
||||
const direct = (conn && conn.endpoint_names) ? [] : defaultDirect;
|
||||
|
||||
if (dashData && dashData.connectivity) {
|
||||
fetch('/api/config.yaml', { method: 'GET' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
const c = j.data && j.data.connectivity ? j.data.connectivity : {};
|
||||
const d = c.direct || defaultDirect;
|
||||
const v = c.via_proxy || defaultDirect;
|
||||
$('direct1Name').value = (d[0] && d[0].name) || '';
|
||||
$('direct1Url').value = (d[0] && d[0].url) || '';
|
||||
$('direct2Name').value = (d[1] && d[1].name) || '';
|
||||
$('direct2Url').value = (d[1] && d[1].url) || '';
|
||||
$('proxy1Name').value = (v[0] && v[0].name) || '';
|
||||
$('proxy1Url').value = (v[0] && v[0].url) || '';
|
||||
$('proxy2Name').value = (v[1] && v[1].name) || '';
|
||||
$('proxy2Url').value = (v[1] && v[1].url) || '';
|
||||
})
|
||||
.catch(() => {
|
||||
$('direct1Name').value = defaultDirect[0].name;
|
||||
$('direct1Url').value = defaultDirect[0].url;
|
||||
$('direct2Name').value = defaultDirect[1].name;
|
||||
$('direct2Url').value = defaultDirect[1].url;
|
||||
$('proxy1Name').value = defaultDirect[0].name;
|
||||
$('proxy1Url').value = defaultDirect[0].url;
|
||||
$('proxy2Name').value = defaultDirect[1].name;
|
||||
$('proxy2Url').value = defaultDirect[1].url;
|
||||
});
|
||||
} else {
|
||||
$('direct1Name').value = defaultDirect[0].name;
|
||||
$('direct1Url').value = defaultDirect[0].url;
|
||||
$('direct2Name').value = defaultDirect[1].name;
|
||||
$('direct2Url').value = defaultDirect[1].url;
|
||||
$('proxy1Name').value = defaultDirect[0].name;
|
||||
$('proxy1Url').value = defaultDirect[0].url;
|
||||
$('proxy2Name').value = defaultDirect[1].name;
|
||||
$('proxy2Url').value = defaultDirect[1].url;
|
||||
}
|
||||
$('connModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeConnSettings() {
|
||||
$('connModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function saveConnSettings(e) {
|
||||
if (e) e.preventDefault();
|
||||
|
||||
const endpoints = {
|
||||
direct: [
|
||||
{ name: $('direct1Name').value.trim(), url: $('direct1Url').value.trim() },
|
||||
{ name: $('direct2Name').value.trim(), url: $('direct2Url').value.trim() },
|
||||
].filter(ep => ep.name && ep.url),
|
||||
via_proxy: [
|
||||
{ name: $('proxy1Name').value.trim(), url: $('proxy1Url').value.trim() },
|
||||
{ name: $('proxy2Name').value.trim(), url: $('proxy2Url').value.trim() },
|
||||
].filter(ep => ep.name && ep.url),
|
||||
};
|
||||
|
||||
if (endpoints.direct.length === 0) {
|
||||
showToast('Укажите хотя бы одну точку для прямого подключения', 'error');
|
||||
return;
|
||||
}
|
||||
if (endpoints.via_proxy.length === 0) {
|
||||
showToast('Укажите хотя бы одну точку для проверки через прокси', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cfgRes = await get('/api/config.yaml');
|
||||
cfgRes.connectivity = endpoints;
|
||||
const resp = await fetch('/api/config.yaml', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfgRes),
|
||||
});
|
||||
const j = await resp.json();
|
||||
if (!j.success) throw new Error(j.error || 'save failed');
|
||||
showToast('Настройки подключения сохранены', 'success');
|
||||
closeConnSettings();
|
||||
} catch (err) {
|
||||
showToast('Ошибка: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
$('connSettingsBtn').addEventListener('click', openConnSettings);
|
||||
$('closeConnModal').addEventListener('click', closeConnSettings);
|
||||
$('cancelConnSettings').addEventListener('click', closeConnSettings);
|
||||
$('connModalBackdrop').addEventListener('click', closeConnSettings);
|
||||
$('connSettingsForm').addEventListener('submit', saveConnSettings);
|
||||
|
||||
|
||||
let toastTimer;
|
||||
function showToast(msg, type) {
|
||||
const t = $('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast ' + type;
|
||||
t.classList.remove('hidden');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
|
||||
}
|
||||
|
||||
// Resize chart
|
||||
window.addEventListener('resize', () => {
|
||||
if (dashData) {
|
||||
const samples = speedMode === 'real' ? dashData.traffic_real : dashData.traffic_avg;
|
||||
drawChart(samples || []);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-refresh
|
||||
setInterval(loadData, 500);
|
||||
|
||||
// ── Custom timeline tooltip ──
|
||||
const tlTooltip = document.createElement('div');
|
||||
tlTooltip.className = 'tl-tooltip';
|
||||
tlTooltip.style.left = '-9999px';
|
||||
tlTooltip.style.top = '-9999px';
|
||||
document.body.appendChild(tlTooltip);
|
||||
|
||||
// ── Chart hover tooltip ──
|
||||
const chartTooltip = document.createElement('div');
|
||||
chartTooltip.className = 'chart-tooltip';
|
||||
document.body.appendChild(chartTooltip);
|
||||
|
||||
let tlTooltipTimer = null;
|
||||
let tlCursorX = 0;
|
||||
let tlCursorY = 0;
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
const zoom = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||
tlCursorX = e.clientX / zoom;
|
||||
tlCursorY = e.clientY / zoom;
|
||||
const bar = e.target.closest('.tl-bar');
|
||||
if (!bar) {
|
||||
tlTooltip.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
if (tlTooltip.classList.contains('visible')) positionTooltip();
|
||||
});
|
||||
|
||||
document.addEventListener('mouseover', (e) => {
|
||||
const bar = e.target.closest('.tl-bar');
|
||||
if (!bar) return;
|
||||
|
||||
const time = bar.dataset.time;
|
||||
const status = bar.dataset.status;
|
||||
|
||||
let statusText, dotClass;
|
||||
if (status === 'up') { statusText = 'Доступен'; dotClass = 'up'; }
|
||||
else if (status === 'down') { statusText = 'Недоступен'; dotClass = 'down'; }
|
||||
else { statusText = 'Нет данных'; dotClass = 'na'; }
|
||||
|
||||
let pingHtml = '';
|
||||
if (status === 'up') {
|
||||
const parts = [];
|
||||
const pingsData = bar.dataset.pings;
|
||||
if (pingsData) {
|
||||
try {
|
||||
const pings = JSON.parse(decodeURIComponent(pingsData));
|
||||
for (const [name, ms] of Object.entries(pings)) {
|
||||
if (ms > 0) parts.push(name + ': ' + ms + ' мс');
|
||||
}
|
||||
} catch (_) {
|
||||
const pingCF = bar.dataset.pingCf;
|
||||
const pingGG = bar.dataset.pingGg;
|
||||
if (pingCF) parts.push('CF: ' + pingCF + ' мс');
|
||||
if (pingGG) parts.push('Google: ' + pingGG + ' мс');
|
||||
}
|
||||
} else {
|
||||
const pingCF = bar.dataset.pingCf;
|
||||
const pingGG = bar.dataset.pingGg;
|
||||
if (pingCF) parts.push('CF: ' + pingCF + ' мс');
|
||||
if (pingGG) parts.push('Google: ' + pingGG + ' мс');
|
||||
}
|
||||
if (parts.length) pingHtml = '<span class="tl-tooltip-ping">' + parts.join(', ') + '</span>';
|
||||
}
|
||||
|
||||
tlTooltip.innerHTML =
|
||||
'<div class="tl-tooltip-time">' + time + '</div>' +
|
||||
'<div class="tl-tooltip-row"><span class="tl-tooltip-dot ' + dotClass + '"></span>' + statusText + pingHtml + '</div>';
|
||||
|
||||
void tlTooltip.offsetWidth;
|
||||
positionTooltip();
|
||||
tlTooltip.classList.add('visible');
|
||||
clearTimeout(tlTooltipTimer);
|
||||
});
|
||||
|
||||
document.addEventListener('mouseout', (e) => {
|
||||
const bar = e.target.closest('.tl-bar');
|
||||
if (!bar) return;
|
||||
const related = e.relatedTarget;
|
||||
if (related && related.closest && related.closest('.tl-bar')) return;
|
||||
tlTooltipTimer = setTimeout(() => tlTooltip.classList.remove('visible'), 80);
|
||||
});
|
||||
|
||||
function positionTooltip() {
|
||||
const tw = tlTooltip.offsetWidth;
|
||||
const th = tlTooltip.offsetHeight;
|
||||
let left = tlCursorX - tw / 2;
|
||||
let top = tlCursorY - th - 12;
|
||||
if (top < 4) top = tlCursorY + 16;
|
||||
if (left < 4) left = 4;
|
||||
if (left + tw > window.innerWidth - 4) left = window.innerWidth - tw - 4;
|
||||
tlTooltip.style.left = left + 'px';
|
||||
tlTooltip.style.top = top + 'px';
|
||||
}
|
||||
|
||||
// ── Chart hover / tooltip logic ──
|
||||
function hideChartTooltip() {
|
||||
chartTooltip.classList.remove('visible');
|
||||
if (chartHoverIdx !== null) {
|
||||
chartHoverIdx = null;
|
||||
redrawChartHover();
|
||||
}
|
||||
}
|
||||
|
||||
function redrawChartHover() {
|
||||
const canvas = $('speedChart');
|
||||
if (!canvas || !chartSamples || chartSamples.length < 2) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
_drawChartContent(ctx, rect.width, rect.height, chartSamples, chartHoverIdx);
|
||||
}
|
||||
|
||||
$('speedChart').addEventListener('mousemove', (e) => {
|
||||
if (!chartSamples || chartSamples.length < 2) { hideChartTooltip(); return; }
|
||||
|
||||
const canvas = $('speedChart');
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left; // offset within canvas in clientX pixels
|
||||
const pad = CHART_PAD;
|
||||
const cw = rect.width - pad.left - pad.right;
|
||||
|
||||
if (mx < pad.left - 6 || mx > rect.width - pad.right + 6) {
|
||||
hideChartTooltip(); return;
|
||||
}
|
||||
|
||||
const timeStart = new Date(chartSamples[0].time).getTime();
|
||||
const timeEnd = new Date(chartSamples[chartSamples.length - 1].time).getTime();
|
||||
const timeRange = timeEnd - timeStart || 1;
|
||||
|
||||
let closestIdx = 0, closestDist = Infinity;
|
||||
for (let i = 0; i < chartSamples.length; i++) {
|
||||
const sx = pad.left + ((new Date(chartSamples[i].time).getTime() - timeStart) / timeRange) * cw;
|
||||
const d = Math.abs(mx - sx);
|
||||
if (d < closestDist) { closestDist = d; closestIdx = i; }
|
||||
}
|
||||
|
||||
if (closestIdx !== chartHoverIdx) {
|
||||
chartHoverIdx = closestIdx;
|
||||
redrawChartHover();
|
||||
}
|
||||
|
||||
// Position tooltip above the hovered data point
|
||||
const s = chartSamples[closestIdx];
|
||||
const sx = pad.left + ((new Date(s.time).getTime() - timeStart) / timeRange) * cw;
|
||||
const zoom = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||
|
||||
chartTooltip.innerHTML =
|
||||
'<div class="chart-tt-time">' + fmtTime(new Date(s.time)) + '</div>' +
|
||||
'<div class="chart-tt-row"><span class="chart-tt-dot rx"></span>' +
|
||||
'<span class="chart-tt-label">↓ RX</span>' +
|
||||
'<span class="chart-tt-val">' + fmtSpeed(s.rx_bps) + '</span></div>' +
|
||||
'<div class="chart-tt-row"><span class="chart-tt-dot tx"></span>' +
|
||||
'<span class="chart-tt-label">↑ TX</span>' +
|
||||
'<span class="chart-tt-val">' + fmtSpeed(s.tx_bps) + '</span></div>';
|
||||
|
||||
const ttW = chartTooltip.offsetWidth;
|
||||
const ttH = chartTooltip.offsetHeight;
|
||||
const anchorX = (rect.left + sx) / zoom;
|
||||
const anchorY = rect.top / zoom;
|
||||
let left = anchorX - ttW / 2;
|
||||
let top = anchorY - ttH - 12;
|
||||
if (top < 4) top = (rect.bottom / zoom) + 8;
|
||||
if (left < 4) left = 4;
|
||||
if (left + ttW > window.innerWidth / zoom - 4) left = window.innerWidth / zoom - ttW - 4;
|
||||
chartTooltip.style.left = left + 'px';
|
||||
chartTooltip.style.top = top + 'px';
|
||||
chartTooltip.classList.add('visible');
|
||||
});
|
||||
|
||||
$('speedChart').addEventListener('mouseleave', hideChartTooltip);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const status = await get('/api/auth/status');
|
||||
if (status.default_password) {
|
||||
$('defaultPwWarning').classList.remove('hidden');
|
||||
}
|
||||
} catch (_) {}
|
||||
await loadData();
|
||||
})();
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AlpineRouter</title>
|
||||
<title>NanoRouter</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -15,17 +15,9 @@
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<h1>AlpineRouter</h1>
|
||||
<h1>NanoRouter</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="hostname" class="hostname"></span>
|
||||
<button class="btn btn-ghost" id="refreshBtn" title="Обновить">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
</svg>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -41,7 +33,14 @@
|
||||
</div>
|
||||
|
||||
<nav class="tab-nav">
|
||||
<a href="/" class="tab-link active">
|
||||
<a href="/home.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
Главная
|
||||
</a>
|
||||
<a href="/ifaces.html" class="tab-link active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
@@ -76,6 +75,13 @@
|
||||
</svg>
|
||||
Прокси
|
||||
</a>
|
||||
<a href="/profile.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
Профиль
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
@@ -83,7 +89,7 @@
|
||||
<div class="spinner"></div>
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
<div id="ifaceGrid" class="iface-grid hidden"></div>
|
||||
<div id="ifaceGrid" class="hidden"></div>
|
||||
</main>
|
||||
|
||||
<!-- Config Modal -->
|
||||
@@ -111,6 +117,14 @@
|
||||
<input type="text" id="cfgLabel" placeholder="Например: WAN, LAN, Гости…" style="font-family:inherit">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="cfgType">Тип интерфейса</label>
|
||||
<div class="segmented" id="typeSwitch">
|
||||
<button type="button" class="seg-btn" data-type="wan">WAN</button>
|
||||
<button type="button" class="seg-btn active" data-type="lan">LAN</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="cfgAuto">
|
||||
@@ -118,7 +132,8 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<!-- Mode: only shown for WAN -->
|
||||
<div id="modeRow" class="form-row">
|
||||
<label>Режим</label>
|
||||
<div class="segmented" id="modeSwitch">
|
||||
<button type="button" class="seg-btn active" data-mode="dhcp">DHCP</button>
|
||||
@@ -135,16 +150,17 @@
|
||||
<label for="cfgNetmask">Маска сети</label>
|
||||
<input type="text" id="cfgNetmask" placeholder="255.255.255.0">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div id="gatewayRow" class="form-row">
|
||||
<label for="cfgGateway">Шлюз</label>
|
||||
<input type="text" id="cfgGateway" placeholder="192.168.1.1">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div id="dnsRow" class="form-row">
|
||||
<label for="cfgDNS">DNS (через пробел)</label>
|
||||
<input type="text" id="cfgDNS" placeholder="8.8.8.8 8.8.4.4">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NAT section: only shown for LAN -->
|
||||
<div id="natSection" class="hidden">
|
||||
<div class="form-divider"></div>
|
||||
<div class="form-row">
|
||||
189
public/login.html
Normal file
189
public/login.html
Normal file
@@ -0,0 +1,189 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NanoRouter — Вход</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="48" height="48">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="login-title">NanoRouter</h1>
|
||||
<p class="login-subtitle">Войдите в панель управления</p>
|
||||
|
||||
<form id="loginForm" class="login-form" autocomplete="off">
|
||||
<div class="form-row">
|
||||
<label for="username">Логин</label>
|
||||
<input type="text" id="username" name="username" placeholder="admin" required autocomplete="username">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="password">Пароль</label>
|
||||
<input type="password" id="password" name="password" placeholder="Пароль" required autocomplete="current-password">
|
||||
</div>
|
||||
<input type="hidden" id="nonce" name="nonce">
|
||||
<input type="hidden" id="response" name="response">
|
||||
<button type="submit" class="btn btn-primary login-btn" id="submitBtn">Войти</button>
|
||||
</form>
|
||||
|
||||
<div id="loginError" class="login-error hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
async function sha256hex(str) {
|
||||
const enc = new TextEncoder();
|
||||
const data = enc.encode(str);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function hashPassword(password) {
|
||||
let salt = 'nano-router-salt-v1';
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
const data = salt + password + i.toString();
|
||||
const hash = await sha256hex(data);
|
||||
salt = hash.substring(0, 32);
|
||||
}
|
||||
const finalHash = await sha256hex(salt + password);
|
||||
return finalHash;
|
||||
}
|
||||
|
||||
async function computeResponse(nonce, passwordHash) {
|
||||
const str = nonce + ':' + passwordHash;
|
||||
return await sha256hex(str);
|
||||
}
|
||||
|
||||
async function login(e) {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById('loginError');
|
||||
const btn = document.getElementById('submitBtn');
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!username || !password) {
|
||||
errEl.textContent = 'Введите логин и пароль';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Вход...';
|
||||
|
||||
try {
|
||||
const chRes = await fetch('/api/auth/challenge');
|
||||
const chJson = await chRes.json();
|
||||
if (!chJson.success) {
|
||||
throw new Error(chJson.error || 'Failed to get challenge');
|
||||
}
|
||||
const nonce = chJson.data.nonce;
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const response = await computeResponse(nonce, passwordHash);
|
||||
|
||||
const loginRes = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nonce, response })
|
||||
});
|
||||
const loginJson = await loginRes.json();
|
||||
|
||||
if (!loginJson.success) {
|
||||
throw new Error(loginJson.error || 'Authentication failed');
|
||||
}
|
||||
|
||||
const redirect = new URLSearchParams(window.location.search).get('redirect') || '/home.html';
|
||||
window.location.href = redirect;
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Войти';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', login);
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.login-body {
|
||||
background: var(--bg);
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 0%, rgba(0, 212, 255, 0.06) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 80% 100%, rgba(0, 255, 136, 0.04) 0%, transparent 50%);
|
||||
}
|
||||
.login-wrapper {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-hi);
|
||||
border-radius: var(--radius);
|
||||
padding: 40px 32px;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: var(--shadow), 0 0 40px rgba(0, 212, 255, 0.06);
|
||||
text-align: center;
|
||||
}
|
||||
.login-logo { margin-bottom: 16px; }
|
||||
.login-logo svg { stroke: var(--accent); filter: drop-shadow(0 0 12px var(--accent-glow)); }
|
||||
.login-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
text-shadow: 0 0 20px rgba(0, 212, 255, 0.2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.login-subtitle {
|
||||
font-size: .85rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
.login-form .form-row label {
|
||||
font-size: .76rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 1rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.login-error {
|
||||
margin-top: 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 51, 102, 0.1);
|
||||
border: 1px solid rgba(255, 51, 102, 0.3);
|
||||
color: var(--danger);
|
||||
font-size: .85rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
362
public/profile.html
Normal file
362
public/profile.html
Normal file
@@ -0,0 +1,362 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NanoRouter — Профиль</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<h1>NanoRouter</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tab-nav">
|
||||
<a href="/home.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
Главная
|
||||
</a>
|
||||
<a href="/ifaces.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
Интерфейсы
|
||||
</a>
|
||||
<a href="/dhcp.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
DHCP сервер
|
||||
</a>
|
||||
<a href="/clients.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
</svg>
|
||||
Клиенты
|
||||
</a>
|
||||
<a href="/firewall.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
<path d="M9 12l2 2 4-4"/>
|
||||
</svg>
|
||||
Файрвол
|
||||
</a>
|
||||
<a href="/proxy.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
|
||||
</svg>
|
||||
Прокси
|
||||
</a>
|
||||
<a href="/profile.html" class="tab-link active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
Профиль
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<main class="profile-main">
|
||||
<div id="defaultWarning" class="alert alert-error hidden" style="margin-bottom:20px">
|
||||
<div>
|
||||
<strong>Внимание!</strong> Используется пароль по умолчанию (admin:admin). Задайте собственный пароль в профиле для безопасности.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-grid">
|
||||
<!-- Credentials -->
|
||||
<div class="dash-card">
|
||||
<h3 class="dash-card-title">Учётные данные</h3>
|
||||
<form id="credForm" class="profile-form">
|
||||
<div class="form-row">
|
||||
<label>Логин</label>
|
||||
<input type="text" id="profUsername" placeholder="admin" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Текущий пароль</label>
|
||||
<input type="password" id="profOldPassword" placeholder="Текущий пароль" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Новый пароль</label>
|
||||
<input type="password" id="profNewPassword" placeholder="Новый пароль" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Подтверждение пароля</label>
|
||||
<input type="password" id="profNewPassword2" placeholder="Повторите новый пароль" autocomplete="new-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- API Key -->
|
||||
<div class="dash-card">
|
||||
<h3 class="dash-card-title">API ключ</h3>
|
||||
<div id="apiKeySection">
|
||||
<div id="noApiKey" class="profile-no-key hidden">
|
||||
<p class="profile-hint">API ключ не создан. Сгенерируйте его для доступа к API без авторизации через браузер.</p>
|
||||
<button id="genApiKeyBtn" class="btn btn-primary">Сгенерировать API ключ</button>
|
||||
</div>
|
||||
<div id="hasApiKey" class="hidden">
|
||||
<div class="api-key-display">
|
||||
<div class="api-key-row">
|
||||
<span class="api-key-label">Ключ:</span>
|
||||
<code id="apiKeyValue" class="api-key-val"></code>
|
||||
<button class="btn-icon" id="copyApiKey" title="Копировать">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="api-key-row">
|
||||
<span class="api-key-label">Префикс:</span>
|
||||
<code id="apiKeyPrefix" class="api-key-val"></code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-section-gap"></div>
|
||||
<button id="revokeApiKeyBtn" class="btn btn-danger btn-sm">Отозвать ключ</button>
|
||||
<div class="profile-section-gap"></div>
|
||||
<div class="profile-api-info">
|
||||
<h4>Использование API ключа</h4>
|
||||
<p>Передайте ключ в заголовке <code>Authorization</code> в формате <code>Bearer</code>:</p>
|
||||
<pre class="profile-code">curl -H "Authorization: Bearer <ваш_ключ>" \
|
||||
http://router:8080/api/interfaces</pre>
|
||||
<p>Все endpoints API доступны с этим ключом. Ключ одноразовый — после отзыва нужно сгенерировать новый.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logout -->
|
||||
<div class="profile-logout">
|
||||
<button id="logoutBtn" class="btn btn-ghost">Выйти из системы</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
let toastTimer;
|
||||
function showToast(msg, type) {
|
||||
const t = $('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast ' + type;
|
||||
t.classList.remove('hidden');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: body ? { 'Content-Type': 'application/json' } : {} };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(path, opts);
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) throw new Error(json.error || 'HTTP ' + res.status);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function sha256hex(str) {
|
||||
const enc = new TextEncoder();
|
||||
const data = enc.encode(str);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function hashPassword(password) {
|
||||
let salt = 'nano-router-salt-v1';
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
const data = salt + password + i.toString();
|
||||
const hash = await sha256hex(data);
|
||||
salt = hash.substring(0, 32);
|
||||
}
|
||||
const finalHash = await sha256hex(salt + password);
|
||||
return finalHash;
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
try {
|
||||
const data = await api('GET', '/api/auth/profile');
|
||||
$('profUsername').value = data.username || 'admin';
|
||||
if (data.default_password) {
|
||||
$('defaultWarning').classList.remove('hidden');
|
||||
} else {
|
||||
$('defaultWarning').classList.add('hidden');
|
||||
}
|
||||
if (data.has_api_key) {
|
||||
$('noApiKey').classList.add('hidden');
|
||||
$('hasApiKey').classList.remove('hidden');
|
||||
$('apiKeyPrefix').textContent = data.api_key_prefix || '';
|
||||
} else {
|
||||
$('noApiKey').classList.remove('hidden');
|
||||
$('hasApiKey').classList.add('hidden');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Ошибка загрузки профиля: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
$('credForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const username = $('profUsername').value.trim();
|
||||
const oldPassword = $('profOldPassword').value;
|
||||
const newPassword = $('profNewPassword').value;
|
||||
const newPassword2 = $('profNewPassword2').value;
|
||||
|
||||
if (newPassword && !oldPassword) {
|
||||
showToast('Введите текущий пароль', 'error');
|
||||
return;
|
||||
}
|
||||
if (newPassword && newPassword !== newPassword2) {
|
||||
showToast('Пароли не совпадают', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = { username };
|
||||
if (newPassword) {
|
||||
body.old_password = oldPassword;
|
||||
body.new_password = newPassword;
|
||||
body.new_password2 = newPassword2;
|
||||
}
|
||||
const data = await api('POST', '/api/auth/profile', body);
|
||||
showToast('Настройки сохранены', 'success');
|
||||
$('profOldPassword').value = '';
|
||||
$('profNewPassword').value = '';
|
||||
$('profNewPassword2').value = '';
|
||||
if (data.default_password) {
|
||||
$('defaultWarning').classList.remove('hidden');
|
||||
} else {
|
||||
$('defaultWarning').classList.add('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Ошибка: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
$('genApiKeyBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
const data = await api('POST', '/api/auth/api-key');
|
||||
$('apiKeyValue').textContent = data.api_key;
|
||||
$('apiKeyPrefix').textContent = data.api_key.substring(0, 8) + '...';
|
||||
$('noApiKey').classList.add('hidden');
|
||||
$('hasApiKey').classList.remove('hidden');
|
||||
showToast('API ключ создан', 'success');
|
||||
} catch (err) {
|
||||
showToast('Ошибка: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
$('copyApiKey').addEventListener('click', () => {
|
||||
const key = $('apiKeyValue').textContent;
|
||||
if (key) {
|
||||
navigator.clipboard.writeText(key).then(() => {
|
||||
showToast('Ключ скопирован', 'success');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$('revokeApiKeyBtn').addEventListener('click', async () => {
|
||||
if (!confirm('Отозвать API ключ? Это действие необратимо.')) return;
|
||||
try {
|
||||
await api('DELETE', '/api/auth/api-key');
|
||||
$('noApiKey').classList.remove('hidden');
|
||||
$('hasApiKey').classList.add('hidden');
|
||||
showToast('API ключ отозван', 'success');
|
||||
} catch (err) {
|
||||
showToast('Ошибка: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
$('logoutBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
} catch (_) {}
|
||||
window.location.href = '/login.html';
|
||||
});
|
||||
|
||||
loadProfile();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.profile-main { padding: 28px; max-width: var(--max-w); margin: 0 auto; }
|
||||
.profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.profile-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.profile-form { display: flex; flex-direction: column; gap: 14px; padding: 0; }
|
||||
.profile-no-key { text-align: center; padding: 20px 0; }
|
||||
.profile-hint { color: var(--muted); font-size: .85rem; margin-bottom: 16px; line-height: 1.5; }
|
||||
.profile-logout { margin-top: 24px; text-align: center; }
|
||||
.profile-api-info {
|
||||
background: rgba(0,0,0,0.2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 14px 18px;
|
||||
font-size: .85rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.profile-api-info h4 { color: var(--accent); margin: 0 0 8px; font-size: .9rem; }
|
||||
.profile-api-info code {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
background: rgba(0,0,0,.3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: .82rem;
|
||||
}
|
||||
.profile-code {
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: .82rem;
|
||||
overflow-x: auto;
|
||||
margin: 10px 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.api-key-display { display: flex; flex-direction: column; gap: 8px; }
|
||||
.api-key-row { display: flex; align-items: center; gap: 10px; }
|
||||
.api-key-label { font-size: .8rem; color: var(--muted); min-width: 60px; }
|
||||
.api-key-val {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: .85rem;
|
||||
color: var(--accent);
|
||||
background: rgba(0,212,255,0.06);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
flex: 1;
|
||||
}
|
||||
.profile-section-gap { height: 16px; }
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AlpineRouter — Прокси</title>
|
||||
<title>NanoRouter — Прокси</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -15,7 +15,7 @@
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<h1>AlpineRouter</h1>
|
||||
<h1>NanoRouter</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="statusBadge" class="svc-badge stopped">Остановлен</span>
|
||||
@@ -23,7 +23,14 @@
|
||||
</header>
|
||||
|
||||
<nav class="tab-nav">
|
||||
<a href="/" class="tab-link">
|
||||
<a href="/home.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
Главная
|
||||
</a>
|
||||
<a href="/ifaces.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
@@ -58,6 +65,13 @@
|
||||
</svg>
|
||||
Прокси
|
||||
</a>
|
||||
<a href="/profile.html" class="tab-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
Профиль
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<main class="proxy-main">
|
||||
@@ -112,23 +126,23 @@
|
||||
<h3 class="dash-card-title">Трафик</h3>
|
||||
<div class="dash-traffic-row">
|
||||
<div class="dash-traffic-item">
|
||||
<span class="dash-traffic-label">↑ Загрузка</span>
|
||||
<span class="dash-traffic-val" id="dashUp">0 B/s</span>
|
||||
<span class="dash-traffic-label">↓ Загрузка</span>
|
||||
<span class="dash-traffic-val" id="dashDown">0 B/s</span>
|
||||
</div>
|
||||
<div class="dash-traffic-item">
|
||||
<span class="dash-traffic-label">↓ Отдача</span>
|
||||
<span class="dash-traffic-val" id="dashDown">0 B/s</span>
|
||||
<span class="dash-traffic-label">↑ Отдача</span>
|
||||
<span class="dash-traffic-val" id="dashUp">0 B/s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-traffic-row">
|
||||
<div class="dash-traffic-item">
|
||||
<span class="dash-traffic-label">↑ Всего</span>
|
||||
<span class="dash-traffic-val dash-traffic-total" id="dashUpTotal">0 B</span>
|
||||
</div>
|
||||
<div class="dash-traffic-item">
|
||||
<span class="dash-traffic-label">↓ Всего</span>
|
||||
<span class="dash-traffic-val dash-traffic-total" id="dashDownTotal">0 B</span>
|
||||
</div>
|
||||
<div class="dash-traffic-item">
|
||||
<span class="dash-traffic-label">↑ Всего</span>
|
||||
<span class="dash-traffic-val dash-traffic-total" id="dashUpTotal">0 B</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-mem-row">
|
||||
<span class="dash-traffic-label">Память</span>
|
||||
@@ -214,13 +228,26 @@
|
||||
|
||||
<!-- Rules Tab -->
|
||||
<div id="tab-rules" class="ptab-content hidden">
|
||||
<div class="section-header" style="margin-bottom:16px">
|
||||
<h2>Правила маршрутизации</h2>
|
||||
<div class="section-desc">Определите, какой трафик куда направляется. Правила применяются сверху вниз.</div>
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom:12px">
|
||||
<button class="btn btn-primary btn-sm" id="addRuleBtn">+ Добавить правило</button>
|
||||
<button class="btn btn-ghost btn-sm" id="addBlockBtn" style="margin-left:8px">+ Блокировка домена</button>
|
||||
<div class="rules-toolbar">
|
||||
<div class="rules-toolbar-left">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15" style="color:var(--accent);flex-shrink:0">
|
||||
<path d="M4 6h16M4 12h16M4 18h7"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="rules-toolbar-title">Правила маршрутизации</div>
|
||||
<div class="rules-toolbar-hint">Применяются сверху вниз</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rules-toolbar-right">
|
||||
<button class="btn btn-ghost btn-sm" id="addBlockBtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><circle cx="12" cy="12" r="10"/><path d="M4.93 4.93l14.14 14.14"/></svg>
|
||||
Блокировка домена
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" id="addRuleBtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Добавить правило
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="rulesList" class="rules-list"></div>
|
||||
</div>
|
||||
@@ -576,7 +603,7 @@ tls://1.1.1.1:853</textarea>
|
||||
<h2 id="proxyModalTitle">Добавить прокси</h2>
|
||||
<button class="btn-icon" id="closeProxyModal" title="Закрыть">✕</button>
|
||||
</div>
|
||||
<form id="proxyForm" autocomplete="off" style="max-height:65vh;overflow-y:auto;padding-right:4px">
|
||||
<form id="proxyForm" autocomplete="off" style="max-height:52vh;overflow-y:auto;padding-right:4px">
|
||||
|
||||
<!-- Type -->
|
||||
<div class="form-row">
|
||||
@@ -1393,7 +1420,7 @@ tls://1.1.1.1:853</textarea>
|
||||
<h2 id="groupModalTitle">Добавить группу</h2>
|
||||
<button class="btn-icon" id="closeGroupModal" title="Закрыть">✕</button>
|
||||
</div>
|
||||
<form id="groupForm" autocomplete="off" style="max-height:65vh;overflow-y:auto;padding-right:4px">
|
||||
<form id="groupForm" autocomplete="off" style="max-height:52vh;overflow-y:auto;padding-right:4px">
|
||||
<div class="form-row">
|
||||
<label for="groupName">Имя группы</label>
|
||||
<input type="text" id="groupName" placeholder="proxy">
|
||||
@@ -1576,7 +1603,7 @@ tls://1.1.1.1:853</textarea>
|
||||
<h2 id="ppModalTitle">Добавить провайдер прокси</h2>
|
||||
<button class="btn-icon" id="closePPModal">✕</button>
|
||||
</div>
|
||||
<form id="ppForm" autocomplete="off" style="max-height:65vh;overflow-y:auto;padding-right:4px">
|
||||
<form id="ppForm" autocomplete="off" style="max-height:52vh;overflow-y:auto;padding-right:4px">
|
||||
<div class="form-row">
|
||||
<label>Имя</label>
|
||||
<input type="text" id="ppName" placeholder="provider1">
|
||||
|
||||
@@ -279,15 +279,19 @@ function renderRules() {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'rule-item';
|
||||
el.innerHTML = `
|
||||
<span class="rule-num">${i + 1}</span>
|
||||
<div class="rule-info">
|
||||
<span class="rule-type">${esc(type)}</span>
|
||||
<span class="rule-value">${esc(value || (type === 'MATCH' ? '*' : ''))}</span>
|
||||
<span class="rule-target">${esc(target)}</span>
|
||||
${flags ? `<span class="rule-flags">${esc(flags)}</span>` : ''}
|
||||
<span class="rule-value">${esc(value || (type === 'MATCH' ? '—' : ''))}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button class="btn btn-danger btn-sm" data-delete-rule="${i}">✕</button>
|
||||
</div>`;
|
||||
<div class="rule-arrow">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
<span class="rule-target">${esc(target)}</span>
|
||||
${flags ? `<span class="rule-flags">${esc(flags)}</span>` : ''}
|
||||
<button class="btn-icon btn-icon-danger rule-del" data-delete-rule="${i}" title="Удалить">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||
</button>`;
|
||||
list.appendChild(el);
|
||||
});
|
||||
}
|
||||
@@ -1893,6 +1897,7 @@ document.getElementById('clearLogsBtn').addEventListener('click', () => {
|
||||
|
||||
const DA = {
|
||||
ws: null,
|
||||
memWs: null,
|
||||
trafficUp: 0,
|
||||
trafficDown: 0,
|
||||
totalUp: 0,
|
||||
@@ -1941,6 +1946,10 @@ function closeDashWS() {
|
||||
try { DA.ws.close(); } catch(e) {}
|
||||
DA.ws = null;
|
||||
}
|
||||
if (DA.memWs) {
|
||||
try { DA.memWs.close(); } catch(e) {}
|
||||
DA.memWs = null;
|
||||
}
|
||||
}
|
||||
|
||||
function openDashWS() {
|
||||
@@ -1960,6 +1969,19 @@ function openDashWS() {
|
||||
ws.onclose = () => { DA.ws = null; };
|
||||
DA.ws = ws;
|
||||
} catch(e) {}
|
||||
const memUrl = proto + '//' + location.host + '/api/mihomo/ws/memory';
|
||||
try {
|
||||
const ws = new WebSocket(memUrl);
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
DA.memory = d.inuse || 0;
|
||||
} catch(err) {}
|
||||
};
|
||||
ws.onerror = () => {};
|
||||
ws.onclose = () => { DA.memWs = null; };
|
||||
DA.memWs = ws;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
let dashTrafficInterval = null;
|
||||
|
||||
1189
public/style.css
1189
public/style.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user