diff --git a/ZapretManager_LuCI.sh b/ZapretManager_LuCI.sh index 20640dd..857efc4 100644 --- a/ZapretManager_LuCI.sh +++ b/ZapretManager_LuCI.sh @@ -1,6 +1,6 @@ #!/bin/sh # Zapret Manager by StressOzz for LuCI installer -# Version: 1.33 +# Version: 1.34 set -e GREEN="\033[1;32m"; CYAN="\033[1;36m"; YELLOW="\033[1;33m"; MAGENTA="\033[1;35m"; BLUE="\033[0;34m"; NC="\033[0m"; DGRAY="\033[38;5;244m" @@ -53,7 +53,7 @@ chmod 0755 /opt/zapret-manager-luci cat > '/opt/zapret-manager-luci/backend.sh' << 'ZM_INSTALLER_EOF' CONF="/etc/config/zapret" -ZM_VERSION="1.33" +ZM_VERSION="1.34" ZM_SCRIPT_URL="https://raw.githubusercontent.com/StressOzz/Zapret-Manager/refs/heads/main/ZapretManager_LuCI.sh" GH_RAW="https://raw.githubusercontent.com" GH_MAIN="https://github.com" @@ -1320,7 +1320,9 @@ rm -rf /opt/zapret-manager-luci /usr/libexec/rpcd/zapret-manager \ /www/luci-static/resources/zapret-manager \ /www/luci-static/resources/bytetube \ /www/luci-static/resources/view/bytetube \ - /tmp/zapret-manager-luci /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null + /tmp/zapret-manager-luci /tmp/luci-indexcache* /tmp/luci-modulecache/* \ + /www/zm /www/zm-webui.html 2>/dev/null +uci -q delete uhttpd.zmweb && uci -q commit uhttpd /etc/init.d/rpcd restart >/dev/null 2>&1 /etc/init.d/uhttpd restart >/dev/null 2>&1 rm -f "$0" @@ -3045,6 +3047,36 @@ _bytetube_fetch() { # URL OUT fi } +health() { + # Быстрая сводка без сетевых запросов: 0 — не установлено, 1 — работает, 2 — установлено, но не работает + local zr=0 zr2=0 bt=0 tg=0 mx=0 doh=0 hs=0 inst=0 bad=0 out="" + if [ -f /etc/init.d/zapret ]; then zr=2; pgrep -f "/opt/zapret/" >/dev/null 2>&1 && zr=1; fi + if [ -f /etc/init.d/zapret2 ]; then zr2=2; /etc/init.d/zapret2 status >/dev/null 2>&1 && zr2=1; fi + if [ -x /usr/bin/bytetube ]; then + bt=2 + out=$(/usr/bin/bytetube status 2>/dev/null) + case "$out" in *'"enabled":true'*'"byedpi":true'*'"hev":true'*) bt=1 ;; esac + fi + if [ -f /etc/init.d/tg-ws-proxy ]; then inst=1; pidof tg-ws-proxy >/dev/null 2>&1 || bad=1; fi + if [ -f "$TG_INIT_GO" ]; then inst=1; pidof tg-ws-proxy-go >/dev/null 2>&1 || bad=1; fi + if [ -f "$TG_INIT_RS" ]; then inst=1; pidof tg-ws-proxy-rs >/dev/null 2>&1 || bad=1; fi + if [ -x /etc/init.d/tgws ]; then inst=1; [ -n "$(tgws status 2>/dev/null)" ] || bad=1; fi + if [ "$inst" = "1" ]; then tg=1; [ "$bad" = "1" ] && tg=2; fi + if [ -x "$MIHOMO_BIN" ]; then + mx=1 + pidof mihomo >/dev/null 2>&1 || mx=2 + if [ -x /etc/init.d/magitrickle ]; then /etc/init.d/magitrickle status >/dev/null 2>&1 || mx=2; fi + if [ -x /etc/init.d/hev-socks5-tunnel ]; then /etc/init.d/hev-socks5-tunnel status >/dev/null 2>&1 || mx=2; fi + fi + if [ "$PKG" = "apk" ]; then apk info -e https-dns-proxy >/dev/null 2>&1 && doh=2 + else opkg list-installed 2>/dev/null | grep -q '^https-dns-proxy ' && doh=2; fi + if [ "$doh" = "2" ]; then pidof https-dns-proxy >/dev/null 2>&1 && doh=1; fi + out=$(hosts_status 2>/dev/null) + case "$out" in *'"enabled":true'*|*'"geohide":"'[a-z]*) hs=1 ;; esac + printf '{"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s}\n' \ + "$zr" "$zr2" "$bt" "$tg" "$mx" "$doh" "$hs" +} + bytetube_installed() { if [ -x /usr/bin/bytetube ]; then printf '{"installed":true}\n' @@ -4743,6 +4775,7 @@ case "$cmd" in mixomo_warp_integrate_action) mixomo_warp_integrate_action ;; mixomo_warp_config_set) mixomo_warp_config_set "$1" ;; bytetube_installed) bytetube_installed ;; + health) health ;; bytetube_action) bytetube_action "$1" ;; *) echo '{"error":"неизвестная команда"}'; exit 1 ;; esac @@ -4830,6 +4863,7 @@ list_methods() { json_add_object "mixomo_warp_integrate_action"; json_close_object json_add_object "mixomo_warp_config_set"; json_add_string "content" "string"; json_close_object json_add_object "bytetube_installed"; json_close_object + json_add_object "health"; json_close_object json_add_object "bytetube_action"; json_add_string "action" "string"; json_close_object json_dump } @@ -4911,6 +4945,7 @@ call_method() { mixomo_warp_integrate_action) "$BACKEND" mixomo_warp_integrate_action ;; mixomo_warp_config_set) json_get_var content content; "$BACKEND" mixomo_warp_config_set "$content" ;; bytetube_installed) "$BACKEND" bytetube_installed ;; + health) "$BACKEND" health ;; bytetube_action) json_get_var action action; "$BACKEND" bytetube_action "$action" ;; *) echo '{"error":"unknown method"}'; return 1 ;; esac @@ -4939,8 +4974,9 @@ cat > '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF' "system_status", "mirror_status", "exclusions_status", "exclusions_file_get", "nfqws_opt_get", "tg_status", "tgws_status", "test_status", "test_results", "zm_update_status", "mixomo_status", "mixomo_config_get", "mixomo_warp_status", - "zapret_latest_version", "bytetube_installed" - ] + "zapret_latest_version", "bytetube_installed", "health" + ], + "system": [ "info" ] }, "uci": [ "bytetube" ], "file": { @@ -5112,6 +5148,33 @@ var callMixomoWarpAction = rpc.declare({ object: 'zapret-manager', method: 'mixo var callMixomoWarpIntegrateAction = rpc.declare({ object: 'zapret-manager', method: 'mixomo_warp_integrate_action', expect: {} }); var callMixomoWarpConfigSet = rpc.declare({ object: 'zapret-manager', method: 'mixomo_warp_config_set', params: ['content'], expect: {} }); var callBytetubeInstalled = rpc.declare({ object: 'zapret-manager', method: 'bytetube_installed', expect: {} }); +var callHealth = rpc.declare({ object: 'zapret-manager', method: 'health', expect: {} }); +var callBoardInfo = rpc.declare({ object: 'system', method: 'info', expect: {} }); + +function parseSize(v) { + var m = String(v == null ? '' : v).trim().match(/^([\d.,]+)\s*([KMGT]?)i?B?$/i); + if (!m) return NaN; + return parseFloat(m[1].replace(',', '.')) * ({ '': 1, K: 1024, M: 1048576, G: 1073741824, T: 1099511627776 })[m[2].toUpperCase()]; +} + +function fmtSize(b) { + if (!isFinite(b)) return '—'; + var u = [ 'Б', 'КБ', 'МБ', 'ГБ', 'ТБ' ], i = 0; + while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; } + return (b >= 100 || i === 0 ? Math.round(b) : b.toFixed(1).replace(/\.0$/, '')) + ' ' + u[i]; +} + +function usageText(used, total) { + if (!isFinite(used) || !isFinite(total) || total <= 0) return '—'; + return fmtSize(used) + ' из ' + fmtSize(total) + ' (' + Math.round(used / total * 100) + '%)'; +} + +/* Единый бейдж состояния службы: 1 — запущен, 2 — остановлен, 0 — не установлен */ +function stateBadge(st, textOk) { + if (st === 1) return badge(true, textOk || 'запущен', ''); + if (st === 2) return badge(false, '', 'остановлен'); + return badge(false, '', 'не установлен'); +} function detectMissingThemeVar() { if (document.documentElement.hasAttribute('data-zm-theme-checked')) return; @@ -5145,7 +5208,7 @@ function injectCss() { } function badge(ok, textOk, textBad) { - var cls = ok ? 'zm-ok' : 'zm-bad'; + var cls = ok ? 'zm-ok' : (textBad === 'не установлен' ? 'zm-off' : 'zm-bad'); return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), ok ? textOk : textBad @@ -5349,7 +5412,13 @@ return baseclass.extend({ mixomoWarpAction: callMixomoWarpAction, mixomoWarpIntegrateAction: callMixomoWarpIntegrateAction, mixomoWarpConfigSet: callMixomoWarpConfigSet, - bytetubeInstalled: callBytetubeInstalled + bytetubeInstalled: callBytetubeInstalled, + health: callHealth, + boardInfo: callBoardInfo, + parseSize: parseSize, + fmtSize: fmtSize, + usageText: usageText, + stateBadge: stateBadge }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/zapret-manager/common.js' @@ -5372,10 +5441,8 @@ return view.extend({ zm.systemStatus().catch(function() { return {}; }), zm.systemInfo().catch(function() { return {}; }), zm.zmUpdateStatus().catch(function() { return {}; }), - zm.mixomoStatus().catch(function() { return {}; }), - zm.tgStatus().catch(function() { return {}; }), - zm.tgwsStatus().catch(function() { return {}; }), - zm.bytetubeInstalled().catch(function() { return {}; }) + zm.health().catch(function() { return {}; }), + zm.boardInfo().catch(function() { return {}; }) ]); }, @@ -5384,10 +5451,8 @@ return view.extend({ var data = all[0], dohData = all[1], hostsData = all[2], sysData = all[3]; var sysInfo = all[4] || {}; var zmUpdate = all[5] || {}; - var mixomoData = all[6] || {}; - var tgData = all[7] || {}; - var tgwsData = all[8] || {}; - var bytetubeData = all[9] || {}; + var healthData = all[6] || {}; + var boardInfo = all[7] || {}; var wrap = E('div', { 'class': 'zm-wrap' }); var overviewEl = E('div', {}); var cards = E('div', { 'class': 'zm-cards' }); @@ -5398,7 +5463,11 @@ return view.extend({ return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, label), node ]); } - function renderOverview(d, doh, hosts, sys, tg, tgws, mixomo, bytetube) { + function st(h, key, fallback) { + return (h && typeof h[key] === 'number') ? h[key] : fallback; + } + + function renderOverview(d, doh, hosts, sys, h) { var hostsEnabled = (hosts.items || []).filter(function(it) { return it.enabled; }).length; var hostsTotal = (hosts.items || []).length; @@ -5407,36 +5476,34 @@ return view.extend({ if (sys.ipv6_enabled) sysFlags.push('IPv6 в Zapret включён'); if (sys.flow_offloading_fix) sysFlags.push('Flow Offloading fix'); - var tgInstalled = !!(tg && (tg.mtproto === 'installed' || tg.socks5 === 'installed' || tg.rust === 'installed')) - || !!(tgws && tgws.installed === 'installed'); - var mixomoInstalled = mixomo && mixomo.mihomo === 'installed'; - var bytetubeInstalled = bytetube && bytetube.installed === true; + var zrSt = st(h, 'zapret', d.zapret === 'installed' ? (d.zapret_running ? 1 : 2) : 0); + var zr2St = st(h, 'zapret2', d.zapret2 === 'installed' ? (d.zapret2_running ? 1 : 2) : 0); + var dohSt = st(h, 'doh', doh.installed ? 1 : 0); + var dohNode = zm.stateBadge(dohSt); + if (dohSt !== 0) dohNode = E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ + dohNode, E('span', {}, DOH_LABELS[doh.current] || doh.current || 'провайдер не определён') + ]); var items = []; - items.push(row('Zapret', d.zapret === 'installed' - ? zm.badge(d.zapret_running === true, 'запущен', 'остановлен') - : zm.badge(false, '', 'не установлен'))); - if (d.zapret === 'installed' && d.zapret_version) items.push(row('Версия Zapret', E('span', {}, d.zapret_version))); - if (d.zapret === 'installed' && d.strategy) items.push(row('Стратегия', E('span', {}, d.strategy))); - items.push(row('Zapret2', d.zapret2 === 'installed' - ? zm.badge(d.zapret2_running === true, 'запущен', 'остановлен') - : zm.badge(false, '', 'не установлен'))); - items.push(row('DNS over HTTPS', doh.installed - ? zm.badge(true, DOH_LABELS[doh.current] || doh.current || 'установлен, провайдер не определён', '') - : zm.badge(false, '', 'не установлен'))); + items.push(row('Zapret', zm.stateBadge(zrSt))); + if (zrSt !== 0 && d.zapret_version) items.push(row('Версия Zapret', E('span', {}, d.zapret_version))); + if (zrSt !== 0 && d.strategy) items.push(row('Стратегия', E('span', {}, d.strategy))); + items.push(row('Zapret2', zm.stateBadge(zr2St))); + items.push(row('ByeTube', zm.stateBadge(st(h, 'bytetube', 0)))); + items.push(row('TG WS Proxy', zm.stateBadge(st(h, 'tg', 0)))); + items.push(row('Mixomo', zm.stateBadge(st(h, 'mixomo', 0)))); + items.push(row('DNS over HTTPS', dohNode)); items.push(row('Домены в hosts', hosts.geohide ? zm.badge(true, 'GeoHide ' + hosts.geohide.toUpperCase(), '') : hostsTotal - ? zm.badge(hostsEnabled > 0, hostsEnabled + ' из ' + hostsTotal + ' включено', 'ничего не включено') + ? (hostsEnabled > 0 ? zm.badge(true, 'включено ' + hostsEnabled + ' из ' + hostsTotal, '') : E('span', { 'class': 'zm-badge zm-off' }, [ E('span', { 'class': 'zm-dot' }), 'ничего не включено' ])) : E('span', {}, '—'))); if (sysFlags.length) items.push(row('Система', E('span', { 'style': 'overflow-wrap:anywhere' }, sysFlags.join(', ')))); - items.push(row('TG WS Proxy', zm.badge(tgInstalled, 'установлен', 'не установлен'))); - items.push(row('Mixomo', zm.badge(mixomoInstalled, 'установлен', 'не установлен'))); - items.push(row('ByeTube', zm.badge(bytetubeInstalled, 'установлен', 'не установлен'))); var half = Math.ceil(items.length / 2); return E('div', { 'class': 'zm-card', 'style': 'margin-bottom:4px' }, [ + E('h3', {}, 'Компоненты'), E('div', { 'class': 'bt-cols' }, [ E('div', { 'class': 'bt-col' }, items.slice(0, half)), E('div', { 'class': 'bt-col' }, items.slice(half)) @@ -5447,29 +5514,28 @@ return view.extend({ function renderCards() { cards.innerHTML = ''; - var sysCard = E('div', { 'class': 'zm-card' }, [ - E('h3', {}, 'Система'), - E('div', { 'class': 'zm-row' }, [ - E('span', { 'class': 'zm-label' }, 'Модель:'), E('span', {}, sysInfo.model || '—') - ]), - E('div', { 'class': 'zm-row' }, [ - E('span', { 'class': 'zm-label' }, 'Архитектура:'), E('span', {}, sysInfo.arch || '—') - ]), - E('div', { 'class': 'zm-row' }, [ - E('span', { 'class': 'zm-label' }, 'OpenWrt:'), E('span', {}, sysInfo.openwrt || '—') - ]), - E('p', { 'class': 'zm-hint', 'style': 'margin-top:10px; margin-bottom:2px' }, 'Место на роутере:'), - E('div', { 'class': 'zm-row' }, [ - E('span', { 'class': 'zm-label' }, '/tmp'), - E('span', {}, sysInfo.tmp_free ? ('занято ' + sysInfo.tmp_used + ' · свободно ' + sysInfo.tmp_free) : '—') - ]), - E('div', { 'class': 'zm-row' }, [ - E('span', { 'class': 'zm-label' }, '/root'), - E('span', {}, sysInfo.root_free ? ('занято ' + sysInfo.root_used + ' · свободно ' + sysInfo.root_free) : '—') - ]) - ]); + var mem = boardInfo.memory || {}; + var ramAvail = mem.available != null ? mem.available : ((mem.free || 0) + (mem.buffered || 0) + (mem.cached || 0)); + var ru = zm.parseSize(sysInfo.root_used), rf = zm.parseSize(sysInfo.root_free); + var tu = zm.parseSize(sysInfo.tmp_used), tf = zm.parseSize(sysInfo.tmp_free); - cards.appendChild(sysCard); + var rows = [ + row('Модель', E('span', {}, sysInfo.model || '—')), + row('Архитектура', E('span', {}, sysInfo.arch || '—')), + row('OpenWrt', E('span', {}, sysInfo.openwrt || '—')) + ]; + var memRows = []; + if (mem.total) memRows.push(row('ОЗУ', E('span', {}, zm.usageText(mem.total - ramAvail, mem.total)))); + memRows.push(row('Флеш', E('span', {}, zm.usageText(ru, ru + rf)))); + memRows.push(row('/tmp', E('span', {}, zm.usageText(tu, tu + tf)))); + + cards.appendChild(E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Система'), + E('div', { 'class': 'bt-cols' }, [ + E('div', { 'class': 'bt-col' }, rows), + E('div', { 'class': 'bt-col' }, memRows) + ]) + ])); } function refreshOverview() { @@ -5478,17 +5544,14 @@ return view.extend({ zm.dohStatus().catch(function() { return {}; }), zm.hostsStatus().catch(function() { return { items: [] }; }), zm.systemStatus().catch(function() { return {}; }), - zm.mixomoStatus().catch(function() { return {}; }), - zm.tgStatus().catch(function() { return {}; }), - zm.tgwsStatus().catch(function() { return {}; }), - zm.bytetubeInstalled().catch(function() { return {}; }) + zm.health().catch(function() { return {}; }) ]).then(function(res) { overviewEl.innerHTML = ''; - overviewEl.appendChild(renderOverview(res[0], res[1], res[2], res[3], res[5], res[6], res[4], res[7])); + overviewEl.appendChild(renderOverview(res[0], res[1], res[2], res[3], res[4])); }); } - overviewEl.appendChild(renderOverview(data, dohData, hostsData, sysData, tgData, tgwsData, mixomoData, bytetubeData)); + overviewEl.appendChild(renderOverview(data, dohData, hostsData, sysData, healthData)); renderCards(); var updateEl = E('div', {}); wrap.appendChild(updateEl); @@ -5497,7 +5560,8 @@ return view.extend({ E('span', { 'class': 'zm-header-by' }, 'by StressOzz · v' + (zmUpdate.current || '?')), E('div', { 'class': 'zm-header-links' }, [ E('a', { 'href': 'http://stresskvn.lol/', 'target': '_blank', 'rel': 'noreferrer' }, 'StressKVN — обход белых списков!'), - E('a', { 'href': 'https://t.me/stressozz_manager', 'target': '_blank', 'rel': 'noreferrer' }, 'Сообщество Telegram') + E('a', { 'href': 'https://t.me/stressozz_manager', 'target': '_blank', 'rel': 'noreferrer' }, 'Сообщество Telegram'), + E('a', { 'href': 'http://' + window.location.hostname + ':7788/', 'target': '_blank', 'rel': 'noreferrer', 'class': 'zm-webui-link' }, 'Web UI ↗') ]) ])); wrap.appendChild(overviewEl); @@ -5999,7 +6063,7 @@ return view.extend({ return; } panelCard.appendChild(E('div', { 'class': 'zm-row' }, [ - E('span', { 'class': 'zm-label' }, 'Сейчас установлена'), + E('span', { 'class': 'zm-label' }, 'Сейчас'), E('span', {}, d.ui_panel === 'zashboard' ? 'Zashboard' : d.ui_panel === 'metacubexd' ? 'MetaCubeXD' : 'не выбрана') ])); panelCard.appendChild(E('div', { 'class': 'zm-grid' }, [ @@ -6100,25 +6164,38 @@ return view.extend({ autoCard.appendChild(E('h3', {}, 'Автоперезапуск Mihomo')); autoCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Периодический перезапуск по расписанию — полезно для VPN-подписок, у которых иногда «зависает» соединение.')); var cur = d.autorestart || ''; - autoCard.appendChild(E('div', { 'class': 'zm-row' }, [ - E('span', { 'class': 'zm-label' }, 'Сейчас'), - E('span', {}, cur === '' ? 'выключен' : (cur.indexOf('every:') === 0 ? 'каждые ' + cur.split(':')[1] + ' ч.' : 'ежедневно в ' + cur.split(':')[1].padStart(2, '0') + ':00')) - ])); - var hourInput = E('input', { 'type': 'number', 'min': '0', 'max': '23', 'placeholder': 'час (0-23)', 'class': 'cbi-input-text', 'style': 'max-width:120px' }); - autoCard.appendChild(E('div', { 'class': 'zm-actions' }, [ - E('button', { 'class': 'cbi-button', 'click': function() { doAuto('off', ''); } }, 'Выключить'), - E('button', { 'class': 'cbi-button', 'click': function() { doAuto('every', '2'); } }, 'Каждые 2 часа'), - E('button', { 'class': 'cbi-button', 'click': function() { doAuto('every', '6'); } }, 'Каждые 6 часов'), - hourInput, + var mode = cur === '' ? 'off' : (cur.indexOf('every:') === 0 ? 'every' + cur.split(':')[1] : 'daily'); + var curHour = mode === 'daily' ? parseInt(cur.split(':')[1], 10) : 4; + if (isNaN(curHour) || curHour < 0 || curHour > 23) curHour = 4; + function hh(h) { return (h < 10 ? '0' : '') + h + ':00'; } + + var opts = []; + for (var h = 0; h < 24; h++) + opts.push(E('option', { 'value': String(h), 'selected': h === curHour ? 'selected' : null }, hh(h))); + var hourSel = E('select', { 'class': 'cbi-input-select zm-hour-select' }, opts); + + var dailyRow = E('div', { 'class': 'zm-actions', 'style': mode === 'daily' ? '' : 'display:none' }, [ + E('span', { 'class': 'zm-label' }, 'Время перезапуска'), + hourSel, E('button', { - 'class': 'cbi-button', - 'click': function() { - var h = hourInput.value.trim(); - if (!/^\d+$/.test(h) || +h < 0 || +h > 23) { zm.toast('Введите час от 0 до 23', 'error'); return; } - doAuto('daily', h); - } - }, 'Ежедневно в это время') + 'class': 'cbi-button cbi-button-positive', + 'click': function() { doAuto('daily', hourSel.value); } + }, mode === 'daily' ? 'Сохранить время' : 'Включить') + ]); + + function tile(id, label, onclick) { + return E('div', { 'class': 'zm-tile' + (mode === id ? ' zm-active' : ''), 'click': onclick }, label); + } + autoCard.appendChild(E('div', { 'class': 'zm-grid' }, [ + tile('off', 'Выключен', function() { if (mode !== 'off') doAuto('off', ''); }), + tile('every2', 'Каждые 2 часа', function() { if (mode !== 'every2') doAuto('every', '2'); }), + tile('every6', 'Каждые 6 часов', function() { if (mode !== 'every6') doAuto('every', '6'); }), + tile('daily', mode === 'daily' ? 'Ежедневно в ' + hh(curHour) : 'Ежедневно в заданное время', function() { + dailyRow.style.display = ''; + hourSel.focus(); + }) ])); + autoCard.appendChild(dailyRow); } function doAuto(mode, value) { @@ -6503,14 +6580,14 @@ return view.extend({ } var fields = [ - E('span', {}, [ E('span', { 'class': 'zm-label' }, 'Статус: '), + E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px' }, [ E('span', { 'class': 'zm-label' }, 'Статус'), d.zapret === 'installed' ? zm.badge(d.zapret_running === true, 'запущен', 'остановлен') : zm.badge(false, '', 'не установлен') ]) ]; - if (d.zapret_version) fields.push(E('span', {}, [ E('span', { 'class': 'zm-label' }, 'Версия: '), E('span', {}, d.zapret_version) ])); - if (d.strategy) fields.push(E('span', {}, [ E('span', { 'class': 'zm-label' }, 'Стратегия: '), E('span', {}, d.strategy) ])); + if (d.zapret_version) fields.push(E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px' }, [ E('span', { 'class': 'zm-label' }, 'Версия'), E('span', {}, d.zapret_version) ])); + if (d.strategy) fields.push(E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px' }, [ E('span', { 'class': 'zm-label' }, 'Стратегия'), E('span', {}, d.strategy) ])); zCardWrap.appendChild(E('div', { 'class': 'zm-card', 'style': 'margin-bottom:10px' }, [ E('h3', {}, 'Zapret'), @@ -7804,11 +7881,11 @@ return view.extend({ netEl.innerHTML = ''; netEl.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'IPv4 (google.com)'), - zm.badge(res.ipv4_ok === true, 'ok, ' + res.ipv4_ms + ' ms', 'недоступен') + zm.badge(res.ipv4_ok === true, 'доступен, ' + res.ipv4_ms + ' мс', 'недоступен') ])); netEl.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'IPv6 (google.com)'), - zm.badge(res.ipv6_ok === true, 'ok, ' + res.ipv6_ms + ' ms', 'недоступен') + zm.badge(res.ipv6_ok === true, 'доступен, ' + res.ipv6_ms + ' мс', 'недоступен') ])); }).catch(function() { netBusy = false; }); } @@ -7852,7 +7929,7 @@ return view.extend({ var mirrorCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Зеркало OpenWrt'), E('div', { 'class': 'zm-row' }, [ - E('span', { 'class': 'zm-label' }, 'Текущее'), mirrorCurrentEl + E('span', { 'class': 'zm-label' }, 'Сейчас'), mirrorCurrentEl ]), mirrorGrid, mirrorLog @@ -7937,7 +8014,7 @@ cat > '/www/luci-static/resources/view/zapret-manager/tgproxy.js' << 'ZM_INSTALL 'require zapret-manager.common as zm'; var VARIANTS = [ - { id: 'mtproto', title: 'MTProto (пакет)', port: 1443, kind: 'proto' }, + { id: 'mtproto', title: 'Go (MTProto)', port: 1443, kind: 'proto' }, { id: 'socks5', title: 'SOCKS5', port: 2080, kind: 'socks' }, { id: 'rust', title: 'Rust (MTProto)', port: 2443, kind: 'proto' } ]; @@ -8279,7 +8356,7 @@ function injectCss() { } function badge(ok, textOk, textBad) { - var cls = ok ? 'zm-ok' : 'zm-bad'; + var cls = ok ? 'zm-ok' : (textBad === 'не установлен' ? 'zm-off' : 'zm-bad'); return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), ok ? textOk : textBad @@ -9524,4 +9601,1871 @@ else PM="opkg"; INSTALL="opkg install"; fi command -v curl >/dev/null 2>&1 || $INSTALL curl >/dev/null 2>&1 || true command -v unzip >/dev/null 2>&1 || $INSTALL unzip >/dev/null 2>&1 || true -echo -e "Zapret Manager ${GREEN}для ${NC}LuCI ${GREEN}установлен!${NC}\n" + +# ─────────────── Web UI (отдельный веб-интерфейс на порту 7788) ─────────────── +mkdir -p /www/zm +chmod 0755 /www/zm +cat > '/www/zm/app.js' << 'ZM_INSTALLER_EOF' +/* + * Zapret Manager — Web UI + * Самостоятельный веб-интерфейс поверх того же rpcd-бэкенда (ubus: zapret-manager). + * Страницы LuCI (view/zapret-manager/*.js) исполняются как есть — здесь лишь + * лёгкая совместимая прослойка: E(), rpc, view, fs, uci, poll, ui, baseclass. + */ +(function () { +'use strict'; + +var BUILD = '__ZMW_BUILD__'; +var RES = '/luci-static/resources/'; +var NULL_SID = '00000000000000000000000000000000'; +var K_SID = 'zmw.sid', K_THEME = 'zmw.theme', K_USER = 'zmw.user'; + +/* ───────────────────────── storage ───────────────────────── */ + +function store(kind) { + try { return kind === 'local' ? window.localStorage : window.sessionStorage; } catch (e) { return null; } +} +function sget(k) { + var v = null; + try { var s = store('session'); v = s && s.getItem(k); } catch (e) {} + if (v) return v; + try { var l = store('local'); v = l && l.getItem(k); } catch (e) {} + return v || null; +} +function sset(k, v, persist) { + ['session', 'local'].forEach(function (kind) { + try { + var s = store(kind); if (!s) return; + if (v == null || (kind === 'local') !== !!persist) s.removeItem(k); + else s.setItem(k, v); + } catch (e) {} + }); +} + +var sid = sget(K_SID); + +/* ───────────────────────── DOM helpers (LuCI-совместимые) ───────────────────────── */ + +function isNode(x) { return x != null && typeof x === 'object' && typeof x.nodeType === 'number'; } +function isObj(x) { return x != null && typeof x === 'object' && !Array.isArray(x) && !isNode(x); } +function typeOf(x) { return x === null ? 'null' : Array.isArray(x) ? 'array' : typeof x; } + +function domAttr(node, attrs) { + for (var k in attrs) { + if (!Object.prototype.hasOwnProperty.call(attrs, k)) continue; + var v = attrs[k]; + if (v == null) continue; + if (typeof v === 'function') node.addEventListener(k, v); + else if (typeof v === 'object') node.setAttribute(k, JSON.stringify(v)); + else node.setAttribute(k, v); + } +} + +function domAppend(node, ch) { + if (Array.isArray(ch)) { + for (var i = 0; i < ch.length; i++) { + var c = ch[i]; + if (isNode(c)) node.appendChild(c); + else if (Array.isArray(c)) domAppend(node, c); + else if (c != null) node.appendChild(document.createTextNode(String(c))); + } + } + else if (typeof ch === 'function') domAppend(node, ch(node)); + else if (isNode(ch)) node.appendChild(ch); + else if (ch != null) node.innerHTML = String(ch); +} + +function E(tag, attrs, data) { + var node; + if (isNode(tag)) node = tag; + else if (Array.isArray(tag)) { + node = document.createDocumentFragment(); + domAppend(node, tag); + return node; + } + else if (typeof tag === 'string' && tag.charAt(0) === '<') { + var t = document.createElement('template'); + t.innerHTML = tag.trim(); + node = t.content.firstChild; + } + else node = document.createElement(tag); + if (attrs != null && !isObj(attrs)) { data = attrs; attrs = null; } + if (attrs) domAttr(node, attrs); + if (data !== undefined) domAppend(node, data); + return node; +} + +function tr(s) { return s; } + +var dom = { + elem: isNode, + create: E, + attr: function (n, k, v) { if (isObj(k)) domAttr(n, k); else { var o = {}; o[k] = v; domAttr(n, o); } }, + append: domAppend, + content: function (n, ch) { while (n.firstChild) n.removeChild(n.firstChild); domAppend(n, ch); return n; }, + parse: function (html) { return E(html); } +}; + +/* ───────────────────────── Классы ───────────────────────── */ + +function extend(Base, props) { + var C = function () { + if (typeof this.__init__ === 'function') return this.__init__.apply(this, arguments); + }; + C.prototype = Object.create(Base.prototype); + C.prototype.constructor = C; + for (var k in props) if (Object.prototype.hasOwnProperty.call(props, k)) C.prototype[k] = props[k]; + C.prototype.super = function (name, args) { + var fn = Base.prototype[name]; + return typeof fn === 'function' ? fn.apply(this, args || []) : undefined; + }; + C.extend = function (p) { return extend(C, p); }; + C.__zmClass = true; + return C; +} +function RootClass() {} +var baseclass = { extend: function (p) { return extend(RootClass, p); } }; +baseclass.singleton = function (p) { return new (baseclass.extend(p))(); }; + +/* ───────────────────────── ubus JSON-RPC ───────────────────────── */ + +var UBUS_ERR = [ 'OK', 'неверная команда', 'неверный аргумент', 'метод не найден', 'объект не найден', + 'нет данных', 'доступ запрещён', 'таймаут', 'не поддерживается', 'неизвестная ошибка', 'нет соединения' ]; + +var rpcSeq = 0; + +function ubus(object, method, params, useSid) { + return fetch('/ubus', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', + credentials: 'omit', + body: JSON.stringify({ + jsonrpc: '2.0', id: ++rpcSeq, method: 'call', + params: [ useSid || sid || NULL_SID, object, method, params || {} ] + }) + }).then(function (r) { + if (r.status === 404) { + var e404 = new Error('На роутере не отвечает /ubus — нужен пакет uhttpd-mod-ubus.'); + e404.noUbus = true; + throw e404; + } + return r.json().catch(function () { throw new Error('Некорректный ответ роутера (HTTP ' + r.status + ')'); }); + }).then(function (msg) { + if (msg && msg.error) { + var e = new Error(msg.error.message || 'Ошибка RPC'); + e.rpcCode = msg.error.code; + if (msg.error.code === -32002 && !useSid) { e.authLost = true; authLost(); } + throw e; + } + if (!msg || !Array.isArray(msg.result)) throw new Error('Некорректный ответ ubus'); + return msg.result; + }); +} + +function declare(o) { + return function () { + var args = arguments, params = {}; + (o.params || []).forEach(function (p, i) { if (args[i] !== undefined) params[p] = args[i]; }); + return ubus(o.object, o.method, params).then(function (res) { + var code = res[0]; + var ret = res.length > 1 ? res[1] : res[0]; + if (code !== 0) { + var text = 'ubus ' + o.object + '.' + o.method + ': ' + (UBUS_ERR[code] || ('код ' + code)); + if (code === 6) toast('Недостаточно прав: ' + o.object + '.' + o.method, 'error'); + if (!o.expect) return code; + var k0 = Object.keys(o.expect)[0]; + if (k0 === undefined || isObj(o.expect[k0])) return { error: text }; + return o.expect[k0]; + } + if (o.expect) { + for (var key in o.expect) { + if (ret != null && key !== '') ret = ret[key]; + if (ret == null || typeOf(ret) !== typeOf(o.expect[key])) ret = o.expect[key]; + break; + } + } + if (typeof o.filter === 'function') ret = o.filter(ret, params); + return ret; + }); + }; +} + +var rpc = { declare: declare, getSessionID: function () { return sid; }, setSessionID: function (s) { sid = s; } }; + +/* ───────────────────────── fs / uci / poll / ui ───────────────────────── */ + +function fsCall(method, params, pick) { + return ubus('file', method, params).then(function (res) { + if (res[0] !== 0) throw new Error((UBUS_ERR[res[0]] || ('код ' + res[0])) + ' (' + (params.path || params.command || method) + ')'); + var r = res[1] || {}; + return pick ? r[pick] : r; + }); +} + +var fs = { + exec: function (command, params, env) { + var p = { command: command }; + if (Array.isArray(params)) p.params = params; + if (isObj(env)) p.env = env; + return fsCall('exec', p); + }, + exec_direct: function (command, params) { + return fs.exec(command, params).then(function (r) { return r.stdout || ''; }); + }, + read: function (path) { return fsCall('read', { path: path }, 'data'); }, + read_direct: function (path) { return fs.read(path); }, + write: function (path, data, mode) { return fsCall('write', { path: path, data: data == null ? '' : String(data), mode: mode }); }, + stat: function (path) { return fsCall('stat', { path: path }); }, + list: function (path) { return fsCall('list', { path: path }, 'entries'); }, + remove: function (path) { return fsCall('remove', { path: path }); } +}; + +var uciValues = {}, uciChanges = {}; +var uci = { + load: function (confs) { + var list = Array.isArray(confs) ? confs : [ confs ]; + return Promise.all(list.map(function (c) { + return ubus('uci', 'get', { config: c }).then(function (res) { + uciValues[c] = (res[0] === 0 && res[1] && res[1].values) || {}; + return c; + }); + })); + }, + unload: function (confs) { + (Array.isArray(confs) ? confs : [ confs ]).forEach(function (c) { delete uciValues[c]; delete uciChanges[c]; }); + }, + get: function (conf, sec, opt) { + var ch = uciChanges[conf] && uciChanges[conf][sec]; + if (opt == null) { + var base = uciValues[conf] && uciValues[conf][sec]; + if (!base && !ch) return null; + var o = {}, k; + for (k in base) o[k] = base[k]; + for (k in ch) { if (ch[k] === null) delete o[k]; else o[k] = ch[k]; } + return o; + } + if (ch && Object.prototype.hasOwnProperty.call(ch, opt)) return ch[opt] === null ? null : ch[opt]; + var s = uciValues[conf] && uciValues[conf][sec]; + return s && s[opt] != null ? s[opt] : null; + }, + get_first: function (conf, type, opt) { + var vals = uciValues[conf] || {}; + for (var s in vals) if (!type || vals[s]['.type'] === type) return uci.get(conf, s, opt); + return null; + }, + set: function (conf, sec, opt, val) { + uciChanges[conf] = uciChanges[conf] || {}; + uciChanges[conf][sec] = uciChanges[conf][sec] || {}; + uciChanges[conf][sec][opt] = (val == null || val === '') ? null : val; + }, + unset: function (conf, sec, opt) { uci.set(conf, sec, opt, null); }, + sections: function (conf, type, cb) { + var vals = uciValues[conf] || {}, out = []; + Object.keys(vals).forEach(function (s) { + if (!type || vals[s]['.type'] === type) { out.push(vals[s]); if (cb) cb(vals[s], s); } + }); + return out; + }, + changes: function () { return Promise.resolve(uciChanges); }, + save: function () { + var jobs = []; + Object.keys(uciChanges).forEach(function (conf) { + Object.keys(uciChanges[conf]).forEach(function (sec) { + var ch = uciChanges[conf][sec], sets = {}, dels = [], hasSet = false; + Object.keys(ch).forEach(function (k) { + if (ch[k] === null) dels.push(k); else { sets[k] = ch[k]; hasSet = true; } + }); + if (hasSet) jobs.push(ubus('uci', 'set', { config: conf, section: sec, values: sets })); + if (dels.length) jobs.push(ubus('uci', 'delete', { config: conf, section: sec, options: dels })); + }); + }); + var confs = Object.keys(uciChanges); + uciChanges = {}; + return Promise.all(jobs).then(function () { return confs.length ? uci.load(confs) : []; }); + }, + apply: function () { + return uci.save().then(function () { return ubus('uci', 'apply', { rollback: false }); }); + } +}; + +var pollJobs = [], pollTimer = null, pollTickN = 0; +function pollEnsure() { + if (pollTimer) return; + pollTimer = setInterval(function () { + pollTickN++; + if (document.hidden) return; + pollJobs.forEach(function (j) { + if (j.busy || pollTickN % j.interval !== 0) return; + j.busy = true; + Promise.resolve().then(j.fn).catch(function () {}).then(function () { j.busy = false; }); + }); + }, 1000); +} +var poll = { + add: function (fn, interval) { + pollJobs.push({ fn: fn, interval: Math.max(1, +interval || 5), busy: false }); + pollEnsure(); + return true; + }, + remove: function (fn) { + var n = pollJobs.length; + pollJobs = pollJobs.filter(function (j) { return j.fn !== fn; }); + return n !== pollJobs.length; + }, + start: function () { pollEnsure(); return true; }, + stop: function () { return true; }, + active: function () { return pollJobs.length > 0; }, + _reset: function () { pollJobs = []; } +}; + +var modalEl = null; +var ui = { + changes: { setIndicator: function () {}, renderChangeIndicator: function () {}, init: function () {} }, + addNotification: function (title, content, cls) { + var txt = (title ? title + ': ' : '') + (isNode(content) ? content.textContent : (content || '')); + toast(txt, /danger|error/.test(cls || '') ? 'error' : (/warning/.test(cls || '') ? 'warning' : 'info')); + return null; + }, + showModal: function (title, children) { + ui.hideModal(); + modalEl = E('div', { 'class': 'zmw-modal-wrap' }, [ + E('div', { 'class': 'zmw-modal' }, [ title ? E('h3', {}, [ title ]) : null, E('div', {}, children) ]) + ]); + document.body.appendChild(modalEl); + requestAnimationFrame(function () { modalEl && modalEl.classList.add('zmw-in'); }); + return modalEl; + }, + hideModal: function () { if (modalEl) { modalEl.remove(); modalEl = null; } }, + createHandlerFn: function (ctx, fn) { + var args = [].slice.call(arguments, 2); + if (typeof fn === 'string') fn = ctx[fn]; + return function (ev) { return fn.apply(ctx, args.concat([ ev ])); }; + }, + awaitReconnect: function () { setTimeout(function () { location.reload(); }, 5000); } +}; + +/* ───────────────────────── view ───────────────────────── */ + +var ViewBase = extend(RootClass, { + load: function () {}, + render: function () {}, + handleSave: null, + handleSaveApply: null, + handleReset: null, + addFooter: function () { return E('div'); } +}); +var viewMod = { extend: function (p) { return ViewBase.extend(p); } }; + +/* ───────────────────────── L ───────────────────────── */ + +var L = { + env: { resource: RES.replace(/\/$/, ''), sessionid: sid }, + resource: function () { return RES + [].slice.call(arguments).join('/'); }, + url: function () { + var p = [].slice.call(arguments).join('/').replace(/^\/+/, ''); + if (/(^|\/)logout$/.test(p)) return '/?logout=1' + location.hash; + return '/cgi-bin/luci/' + p; + }, + bind: function (fn, self) { var a = [].slice.call(arguments, 2); return function () { return fn.apply(self, a.concat([].slice.call(arguments))); }; }, + isObject: isObj, + toArray: function (x) { return x == null ? [] : Array.isArray(x) ? x : [ x ]; }, + resolveDefault: function (p, d) { return Promise.resolve(p).catch(function () { return d; }); }, + sortedKeys: function (o) { return Object.keys(o || {}).sort(); }, + hasSystemFeature: function () { return false; }, + raise: function (t, m) { var e = new Error(m || t); e.name = t; throw e; }, + error: function (t, m) { L.raise(t, m); }, + dom: dom, ui: ui, Poll: poll, Class: baseclass, + require: function (n) { return requireModule(n); } +}; +window.L = L; +window.E = E; +window._ = tr; + +/* ───────────────────────── Загрузчик модулей ───────────────────────── */ + +var BUILTIN = { baseclass: baseclass, rpc: rpc, ui: ui, view: viewMod, fs: fs, uci: uci, poll: poll, dom: dom }; +var srcCache = {}, modCache = {}; + +function modUrl(name) { return RES + name.replace(/\./g, '/') + '.js'; } + +function fetchSrc(name) { + if (!srcCache[name]) { + srcCache[name] = fetch(modUrl(name) + '?v=' + BUILD, { cache: 'no-cache' }).then(function (r) { + if (!r.ok) throw new Error('Не удалось загрузить ' + name + ' (HTTP ' + r.status + ')'); + return r.text(); + }); + srcCache[name].catch(function () { delete srcCache[name]; }); + } + return srcCache[name]; +} + +/* Точечные правки текста: в Web UI нет «выхода из LuCI» и меню LuCI */ +var SRC_PATCHES = [ + [ /E\('h2', \{\}, 'Zapret Manager LuCI'\)/g, "E('h2', {}, 'Zapret Manager')" ], + [ /E\('h3', \{\}, 'Удалить Zapret Manager LuCI'\)/g, "E('h3', {}, 'Удалить Zapret Manager')" ], + [ /выведены из LuCI/g, 'выведены из панели' ], + [ /закрытие LuCI/g, 'закрытие вкладки' ], + [ /Выходим из LuCI/g, 'Выходим из панели' ], + [ /Удалить панель из LuCI/g, 'Удалить панель' ], + [ /саму программу-оболочку из LuCI/g, 'саму программу-оболочку (приложение LuCI и этот Web UI)' ], + [ /(var tabBar = E\('div', \{ 'class': 'zm-actions)'/g, "$1 zmw-tabs'" ] +]; + +var REQ_RE = /^[ \t]*'require[ \t]+([A-Za-z0-9_.\-\/]+)(?:[ \t]+as[ \t]+([A-Za-z_$][A-Za-z0-9_$]*))?[ \t]*'[ \t]*;?/gm; + +function evaluate(name, src) { + SRC_PATCHES.forEach(function (p) { src = src.replace(p[0], p[1]); }); + var deps = [], m; + REQ_RE.lastIndex = 0; + while ((m = REQ_RE.exec(src))) deps.push({ name: m[1], as: m[2] || m[1].split(/[.\/]/).pop() }); + return Promise.all(deps.map(function (d) { return requireModule(d.name); })).then(function (insts) { + var names = [ 'E', '_', 'N_', 'L' ].concat(deps.map(function (d) { return d.as; })); + var fn = Function.apply(null, names.concat([ src + '\n//# sourceURL=' + location.origin + modUrl(name) ])); + return fn.apply(window, [ E, tr, tr, L ].concat(insts)); + }); +} + +function postPatch(name, inst) { + if (name === 'zapret-manager.common' && inst) { + inst.refreshBanner = function () { + return E('div', { 'class': 'zm-refresh-banner zm-show' }, [ + E('span', {}, 'Состав компонентов изменился — обновите страницу, чтобы увидеть актуальное состояние.'), + E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function () { location.reload(); } }, 'Обновить') + ]); + }; + var origPoll = inst.pollJob; + inst.pollJob = function (job, logEl, onDone, onTick) { + return origPoll.call(this, job, logEl, function (ok) { + try { if (typeof onDone === 'function') onDone(ok); } + finally { setTimeout(function () { refreshShellStatus(); refreshMemory(); }, 1000); } + }, onTick); + }; + setTimeout(refreshShellStatus, 300); + } + return inst; +} + +function requireModule(name) { + if (BUILTIN[name]) return Promise.resolve(BUILTIN[name]); + if (!modCache[name]) { + modCache[name] = fetchSrc(name).then(function (src) { return evaluate(name, src); }).then(function (res) { + return postPatch(name, (typeof res === 'function' && res.__zmClass) ? new res() : res); + }); + modCache[name].catch(function () { delete modCache[name]; }); + } + return modCache[name]; +} + +/* ───────────────────────── Иконки ───────────────────────── */ + +var ICONS = { + dashboard: '', + shield: '', + bolt: '', + list: '', + globe: '', + send: '', + layers: '', + play: '', + cpu: '', + logout: '', + sun: '', + moon: '', + refresh: '', + menu: '', + close: '', + external: '', + eye: '', + eyeOff: '', + user: '', + lock: '', + arrow: '', + alert: '', + rocket: '', + telegram: '' +}; + +function icon(name, cls) { + var s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + s.setAttribute('viewBox', '0 0 24 24'); + s.setAttribute('fill', 'none'); + s.setAttribute('stroke', 'currentColor'); + s.setAttribute('stroke-width', '1.8'); + s.setAttribute('stroke-linecap', 'round'); + s.setAttribute('stroke-linejoin', 'round'); + s.setAttribute('aria-hidden', 'true'); + s.setAttribute('class', 'zmw-i' + (cls ? ' ' + cls : '')); + s.innerHTML = ICONS[name] || ''; + return s; +} + +var logoSeq = 0; +function logo(cls) { + var id = 'zmwz' + (++logoSeq); + return E('div', { 'class': 'zmw-logo' + (cls ? ' ' + cls : '') }, [ + E('') + ]); +} + +/* ───────────────────────── Тосты ───────────────────────── */ + +function toast(message, kind, duration) { + var c = document.getElementById('zm-toast-container'); + if (!c) { c = E('div', { id: 'zm-toast-container' }); document.body.appendChild(c); } + var el = E('div', { 'class': 'zm-toast zm-toast-' + (kind || 'info') }, [ + E('span', { 'class': 'zm-toast-icon' }, [ kind === 'error' ? '✕' : (kind === 'warning' ? '!' : '✓') ]), + E('span', { 'class': 'zm-toast-text' }, [ message ]) + ]); + c.appendChild(el); + requestAnimationFrame(function () { el.classList.add('zm-toast-show'); }); + var hide = function () { + el.classList.remove('zm-toast-show'); + setTimeout(function () { el.remove(); }, 250); + }; + el.addEventListener('click', hide); + setTimeout(hide, duration || (kind === 'error' ? 12000 : 6000)); +} + +/* ───────────────────────── Тема ───────────────────────── */ + +var mqDark = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; +function themePref() { var t = sget(K_THEME); return t === 'light' || t === 'dark' ? t : 'auto'; } +function themeEffective() { var p = themePref(); return p === 'auto' ? ((mqDark && !mqDark.matches) ? 'light' : 'dark') : p; } +function applyTheme() { + var t = themeEffective(); + var h = document.documentElement; + h.setAttribute('data-theme', t); + h.classList.toggle('zm-theme-dark', t === 'dark'); + h.setAttribute('data-zm-theme-checked', '1'); + var mc = document.querySelector('meta[name="theme-color"]'); + if (mc) mc.setAttribute('content', t === 'dark' ? '#0a0c12' : '#f3f5fa'); + Array.prototype.forEach.call(document.querySelectorAll('.zmw-theme-toggle'), function (b) { + b.innerHTML = ''; + b.appendChild(icon(t === 'dark' ? 'sun' : 'moon')); + b.title = t === 'dark' ? 'Светлая тема' : 'Тёмная тема'; + }); +} +if (mqDark && mqDark.addEventListener) mqDark.addEventListener('change', function () { if (themePref() === 'auto') applyTheme(); }); +function toggleTheme() { + sset(K_THEME, themeEffective() === 'dark' ? 'light' : 'dark', true); + document.documentElement.classList.add('zmw-theme-anim'); + applyTheme(); + setTimeout(function () { document.documentElement.classList.remove('zmw-theme-anim'); }, 400); +} + +/* ───────────────────────── Маршруты ───────────────────────── */ + +var ROUTES = [ + { id: 'dashboard', title: 'Дашборд', sub: 'Состояние всех компонентов', icon: 'dashboard', group: 'Обзор' }, + { id: 'strategy', title: 'Zapret', sub: 'Стратегии, тесты, YouTube, игры, Discord и исключения', icon: 'shield', group: 'Обход блокировок', dot: 'zapret' }, + { id: 'zapret2', title: 'Zapret2', sub: 'Установка и управление Zapret2', icon: 'bolt', group: 'Обход блокировок', dot: 'zapret2' }, + { id: 'bytetube', title: 'ByeTube', sub: 'YouTube через ByeDPI', icon: 'play', group: 'Обход блокировок', dot: 'bytetube' }, + { id: 'tgproxy', title: 'TG WS Proxy', sub: 'Прокси для Telegram', icon: 'send', group: 'Обход блокировок', dot: 'tg' }, + { id: 'mixomo', title: 'Mixomo', sub: 'Mihomo, MagiTrickle и WARP', icon: 'layers', group: 'Обход блокировок', dot: 'mixomo' }, + { id: 'hosts', title: 'Hosts', sub: 'Домены в hosts и списки GeoHide', icon: 'list', group: 'Сеть', dot: 'hosts' }, + { id: 'doh', title: 'DNS over HTTPS', sub: 'Шифрованный DNS для всей сети', icon: 'globe', group: 'Сеть', dot: 'doh' }, + { id: 'system', title: 'Система', sub: 'Параметры роутера, зеркала и обслуживание', icon: 'cpu', group: 'Сервис' } +]; +var ROUTE_BY_ID = {}; +ROUTES.forEach(function (r) { ROUTE_BY_ID[r.id] = r; }); + +function currentRoute() { + var id = (location.hash || '').replace(/^#\/?/, '').split(/[/?]/)[0]; + return ROUTE_BY_ID[id] ? id : 'dashboard'; +} + +/* ───────────────────────── Оболочка ───────────────────────── */ + +var root, shell = null, viewEl, titleEl, subEl, navLinks = {}, navDots = {}, statusPill, deviceEl, verEl, updateEl, memEl; + +function buildShell() { + var nav = E('nav', { 'class': 'zmw-nav', 'aria-label': 'Разделы' }); + var lastGroup = null; + ROUTES.forEach(function (r) { + if (r.group !== lastGroup) { nav.appendChild(E('div', { 'class': 'zmw-nav-group' }, [ r.group ])); lastGroup = r.group; } + var dot = r.dot ? E('span', { 'class': 'zmw-nav-dot' }) : null; + var a = E('a', { 'class': 'zmw-nav-item', 'href': '#/' + r.id, 'data-route': r.id }, [ + E('span', { 'class': 'zmw-nav-ico' }, [ icon(r.icon) ]), + E('span', { 'class': 'zmw-nav-label' }, [ r.title ]), + dot + ]); + a.addEventListener('click', function () { closeDrawer(); }); + navLinks[r.id] = a; + if (dot) navDots[r.dot] = dot; + nav.appendChild(a); + }); + + deviceEl = E('div', { 'class': 'zmw-device' }, [ E('div', { 'class': 'zmw-device-model' }, [ 'Роутер' ]), E('div', { 'class': 'zmw-device-sub' }, [ location.hostname ]) ]); + verEl = E('div', { 'class': 'zmw-brand-sub' }, [ 'Web UI' ]); + updateEl = E('a', { 'class': 'zmw-update', 'href': '#/dashboard', 'hidden': '' }, [ 'Доступно обновление' ]); + memEl = E('div', { 'class': 'zmw-mem' }); + + var side = E('aside', { 'class': 'zmw-side', 'id': 'zmw-side' }, [ + E('div', { 'class': 'zmw-brand' }, [ + logo(), + E('div', { 'class': 'zmw-brand-text' }, [ E('div', { 'class': 'zmw-brand-name' }, [ 'Zapret Manager' ]), verEl ]), + E('button', { 'class': 'zmw-icon-btn zmw-drawer-close', 'type': 'button', 'aria-label': 'Закрыть меню', 'click': closeDrawer }, [ icon('close') ]) + ]), + updateEl, + nav, + E('div', { 'class': 'zmw-side-foot' }, [ + deviceEl, + memEl, + E('div', { 'class': 'zmw-side-links' }, [ + E('a', { 'href': luciUrl(), 'target': '_blank', 'rel': 'noreferrer' }, [ icon('external'), 'Открыть LuCI' ]) + ]), + E('div', { 'class': 'zmw-credit' }, [ 'by StressOzz' ]) + ]) + ]); + + titleEl = E('h1', { 'class': 'zmw-title' }, [ '' ]); + subEl = E('div', { 'class': 'zmw-sub' }, [ '' ]); + viewEl = E('main', { 'class': 'zmw-view', 'id': 'zmw-view' }); + + var themeBtn = E('button', { 'class': 'zmw-icon-btn zmw-theme-toggle', 'type': 'button', 'click': toggleTheme }); + var top = E('header', { 'class': 'zmw-top' }, [ + E('button', { 'class': 'zmw-icon-btn zmw-burger', 'type': 'button', 'aria-label': 'Меню', 'click': openDrawer }, [ icon('menu') ]), + E('div', { 'class': 'zmw-titles' }, [ titleEl, subEl ]), + E('div', { 'class': 'zmw-top-actions' }, [ + E('a', { 'class': 'zmw-icon-btn zmw-link-btn zmw-link-kvn', 'href': 'http://stresskvn.lol/', 'target': '_blank', 'rel': 'noreferrer', 'title': 'StressKVN — обход белых списков!' }, [ + icon('rocket'), E('span', { 'class': 'zmw-lbl-full' }, [ 'StressKVN — обход белых списков!' ]), E('span', { 'class': 'zmw-lbl-short' }, [ 'StressKVN' ]) + ]), + E('a', { 'class': 'zmw-icon-btn zmw-link-btn zmw-link-tg', 'href': 'https://t.me/stressozz_manager', 'target': '_blank', 'rel': 'noreferrer', 'title': 'Сообщество Telegram' }, [ + icon('telegram'), E('span', { 'class': 'zmw-lbl-full' }, [ 'Сообщество Telegram' ]), E('span', { 'class': 'zmw-lbl-short' }, [ 'Telegram' ]) + ]), + E('span', { 'class': 'zmw-top-sep' }), + E('button', { 'class': 'zmw-icon-btn', 'type': 'button', 'title': 'Обновить страницу', 'click': function (ev) { + var b = ev.currentTarget; b.classList.add('zmw-spin'); setTimeout(function () { b.classList.remove('zmw-spin'); }, 700); + route(true); + } }, [ icon('refresh') ]), + themeBtn, + E('button', { 'class': 'zmw-icon-btn zmw-logout', 'type': 'button', 'title': 'Выйти', 'click': function () { logout(); } }, [ icon('logout'), E('span', {}, [ 'Выйти' ]) ]) + ]) + ]); + + shell = E('div', { 'class': 'zmw-shell' }, [ + side, + E('div', { 'class': 'zmw-main' }, [ top, viewEl, E('footer', { 'class': 'zmw-foot' }, [ 'Zapret Manager Web UI · тот же бэкенд, что и в LuCI' ]) ]), + E('div', { 'class': 'zmw-scrim', 'click': closeDrawer }) + ]); + root.appendChild(shell); + applyTheme(); +} + +function luciUrl() { + return location.protocol + '//' + location.hostname + '/cgi-bin/luci/admin/services/zapret-manager/dashboard'; +} + +function openDrawer() { document.body.classList.add('zmw-drawer-open'); } +function closeDrawer() { document.body.classList.remove('zmw-drawer-open'); } + +var callStatus = declare({ object: 'zapret-manager', method: 'status', expect: {} }); +var callSysInfo = declare({ object: 'zapret-manager', method: 'system_info', expect: {} }); +var callHealth = declare({ object: 'zapret-manager', method: 'health', expect: {} }); +var callUpd = declare({ object: 'zapret-manager', method: 'zm_update_status', expect: {} }); +var callBoardInfo = declare({ object: 'system', method: 'info', expect: {} }); + +function parseSize(v) { + var m = String(v || '').trim().match(/^([\d.,]+)\s*([KMGT]?)i?B?$/i); + if (!m) return NaN; + var n = parseFloat(m[1].replace(',', '.')); + var mul = { '': 1, K: 1024, M: 1048576, G: 1073741824, T: 1099511627776 }[m[2].toUpperCase()]; + return n * mul; +} +function fmtSize(b) { + if (!isFinite(b)) return '—'; + var u = [ 'Б', 'КБ', 'МБ', 'ГБ', 'ТБ' ], i = 0; + while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; } + return (b >= 100 || i === 0 ? Math.round(b) : b.toFixed(1).replace(/\.0$/, '')) + ' ' + u[i]; +} +var memRows = {}; +function memRow(key, label, used, total) { + var pct = total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : 0; + var r = memRows[key]; + if (!r) { + r = memRows[key] = { + el: E('div', { 'class': 'zmw-mem-row' }, [ + E('div', { 'class': 'zmw-mem-head' }, [ E('span', { 'class': 'zmw-mem-label' }, [ label ]), E('span', { 'class': 'zmw-mem-val' }) ]), + E('div', { 'class': 'zmw-mem-bar' }, [ E('i') ]) + ]) + }; + r.order = key === 'ram' ? 0 : key === 'flash' ? 1 : 2; + var before = null; + Object.keys(memRows).forEach(function (k) { var o = memRows[k]; if (o !== r && o.el.parentNode && o.order > r.order && (!before || o.order < before.order)) before = o; }); + memEl.insertBefore(r.el, before ? before.el : null); + } + r.el.querySelector('.zmw-mem-val').textContent = fmtSize(used) + ' из ' + fmtSize(total); + var bar = r.el.querySelector('i'); + bar.style.width = pct.toFixed(1) + '%'; + r.el.classList.toggle('zmw-mem-hi', pct >= 85); + r.el.classList.toggle('zmw-mem-mid', pct >= 65 && pct < 85); + r.el.title = label + ': занято ' + Math.round(pct) + '%'; +} +function refreshMemory() { + if (!shell || !sid) return; + callBoardInfo().then(function (i) { + var m = i && i.memory; + if (!m || !m.total) return; + var avail = m.available != null ? m.available : (m.free + (m.buffered || 0) + (m.cached || 0)); + memRow('ram', 'ОЗУ', m.total - avail, m.total); + }).catch(function () {}); + callSysInfo().then(function (i) { + if (!i || i.error) return; + var ru = parseSize(i.root_used), rf = parseSize(i.root_free); + if (isFinite(ru) && isFinite(rf)) memRow('flash', 'Флеш', ru, ru + rf); + }).catch(function () {}); +} + +var statusBusy = false; +var DOT_TEXT = { 1: 'работает', 2: 'не работает' }; +function refreshShellStatus() { + if (!shell || !sid || statusBusy) return; + statusBusy = true; + callHealth().then(function (h) { + if (h && typeof h.zapret === 'number') return h; + /* старый бэкенд без health — хотя бы Zapret/Zapret2 */ + return callStatus().then(function (d) { + d = d || {}; + return { + zapret: d.zapret === 'installed' ? (d.zapret_running ? 1 : 2) : 0, + zapret2: d.zapret2 === 'installed' ? (d.zapret2_running ? 1 : 2) : 0 + }; + }); + }).then(function (h) { + Object.keys(navDots).forEach(function (k) { setDot(navDots[k], h[k]); }); + }).catch(function () {}).then(function () { statusBusy = false; }); +} +function setDot(el, st) { + if (!el) return; + el.className = 'zmw-nav-dot' + (st === 1 ? ' zmw-on' : st === 2 ? ' zmw-stop' : ''); + el.title = DOT_TEXT[st] || ''; +} + +function loadShellInfo() { + callSysInfo().then(function (i) { + if (!i || i.error) return; + deviceEl.firstChild.textContent = String(i.model || 'Роутер').replace(/\s*\(.*\)\s*$/, '') || i.model; + deviceEl.title = [ i.model, i.openwrt ? 'OpenWrt ' + i.openwrt : '', i.arch ].filter(Boolean).join('\n'); + deviceEl.lastChild.textContent = [ i.openwrt ? 'OpenWrt ' + i.openwrt : '', i.arch || '' ].filter(Boolean).join(' · ') || location.hostname; + }).catch(function () {}); + callUpd().then(function (u) { + if (!u || u.error) return; + if (u.current) verEl.textContent = 'Web UI · v' + u.current; + if (u.latest && u.current && u.latest !== u.current) { + updateEl.hidden = false; + updateEl.textContent = 'Доступна версия ' + u.latest; + } + }).catch(function () {}); +} + +var shellTimer = null; +function startShellPolling() { + if (shellTimer) return; + shellTimer = setInterval(function () { if (!document.hidden) { refreshShellStatus(); refreshMemory(); } }, 15000); +} + +/* ───────────────────────── Рендер страниц ───────────────────────── */ + +var routeToken = 0; + +function skeleton() { + return E('div', { 'class': 'zmw-skel-wrap' }, [ + E('div', { 'class': 'zmw-skel zmw-skel-bar' }), + E('div', { 'class': 'zmw-skel-grid' }, [ + E('div', { 'class': 'zmw-skel zmw-skel-card' }), + E('div', { 'class': 'zmw-skel zmw-skel-card' }), + E('div', { 'class': 'zmw-skel zmw-skel-card' }) + ]), + E('div', { 'class': 'zmw-skel zmw-skel-wide' }) + ]); +} + +function errorCard(err, retry) { + return E('div', { 'class': 'zmw-error' }, [ + E('div', { 'class': 'zmw-error-ico' }, [ icon('alert') ]), + E('div', {}, [ + E('h3', {}, [ 'Не удалось открыть страницу' ]), + E('p', {}, [ (err && err.message) || String(err) ]), + E('button', { 'class': 'cbi-button cbi-button-positive', 'click': retry }, [ 'Повторить' ]) + ]) + ]); +} + +function route(force) { + if (!shell || !sid) return; + var id = currentRoute(); + var r = ROUTE_BY_ID[id]; + var token = ++routeToken; + + Object.keys(navLinks).forEach(function (k) { navLinks[k].classList.toggle('zmw-active', k === id); }); + titleEl.textContent = r.title; + subEl.textContent = r.sub; + document.title = r.title + ' · Zapret Manager'; + + poll._reset(); + ui.hideModal(); + refreshShellStatus(); + viewEl.innerHTML = ''; + viewEl.appendChild(skeleton()); + if (!force) window.scrollTo(0, 0); + + fetchSrc('view.zapret-manager.' + id) + .then(function (src) { return evaluate('view.zapret-manager.' + id, src); }) + .then(function (Cls) { + if (token !== routeToken) return; + var v = (typeof Cls === 'function' && Cls.__zmClass) ? new Cls() : Cls; + return Promise.resolve(v.load()).then(function (data) { + if (token !== routeToken) return; + return Promise.resolve(v.render(data)); + }).then(function (node) { + if (token !== routeToken || !node) return; + var page = E('div', { 'class': 'zmw-page' }, [ node ]); + viewEl.innerHTML = ''; + viewEl.appendChild(page); + }); + }) + .catch(function (err) { + if (token !== routeToken || (err && err.authLost)) return; + console.error(err); + viewEl.innerHTML = ''; + viewEl.appendChild(errorCard(err, function () { route(true); })); + }); +} + +/* ───────────────────────── Вход / выход ───────────────────────── */ + +var loginEl = null, authLostShown = false; + +function showLogin(note, noteKind) { + if (loginEl) return; + var user = E('input', { 'type': 'text', 'name': 'username', 'autocomplete': 'username', 'autocapitalize': 'off', 'spellcheck': 'false', 'value': sget(K_USER) || 'root', 'required': '' }); + var pass = E('input', { 'type': 'password', 'name': 'password', 'autocomplete': 'current-password', 'placeholder': '••••••••' }); + var eye = E('button', { 'type': 'button', 'class': 'zmw-eye', 'tabindex': '-1', 'aria-label': 'Показать пароль' }, [ icon('eye') ]); + eye.addEventListener('click', function () { + var show = pass.type === 'password'; + pass.type = show ? 'text' : 'password'; + eye.innerHTML = ''; eye.appendChild(icon(show ? 'eyeOff' : 'eye')); + pass.focus(); + }); + var remember = E('input', { 'type': 'checkbox', 'checked': '' }); + var errEl = E('div', { 'class': 'zmw-login-msg' + (note ? ' zmw-show zmw-' + (noteKind || 'info') : '') }, [ note || '' ]); + var btn = E('button', { 'type': 'submit', 'class': 'zmw-btn-primary' }, [ E('span', {}, [ 'Войти' ]), icon('arrow') ]); + + var card = E('form', { 'class': 'zmw-login-card', 'autocomplete': 'on' }, [ + logo('zmw-logo-lg'), + E('h1', {}, [ 'Zapret Manager' ]), + E('p', { 'class': 'zmw-login-sub' }, [ 'Панель управления роутером ', E('b', {}, [ location.hostname ]) ]), + E('label', { 'class': 'zmw-field' }, [ E('span', { 'class': 'zmw-field-label' }, [ 'Пользователь' ]), E('div', { 'class': 'zmw-input' }, [ icon('user'), user ]) ]), + E('label', { 'class': 'zmw-field' }, [ E('span', { 'class': 'zmw-field-label' }, [ 'Пароль' ]), E('div', { 'class': 'zmw-input' }, [ icon('lock'), pass, eye ]) ]), + E('label', { 'class': 'zmw-check' }, [ remember, E('span', { 'class': 'zmw-check-box' }), E('span', {}, [ 'Запомнить на этом устройстве' ]) ]), + errEl, + btn, + E('div', { 'class': 'zmw-login-foot' }, [ 'Логин и пароль те же, что и для LuCI' ]) + ]); + + function fail(msg) { + errEl.className = 'zmw-login-msg zmw-show zmw-error'; + errEl.textContent = msg; + card.classList.remove('zmw-shake'); void card.offsetWidth; card.classList.add('zmw-shake'); + btn.disabled = false; + btn.classList.remove('zmw-loading'); + } + + card.addEventListener('submit', function (ev) { + ev.preventDefault(); + if (btn.disabled) return; + btn.disabled = true; + btn.classList.add('zmw-loading'); + errEl.className = 'zmw-login-msg'; + ubus('session', 'login', { username: user.value.trim(), password: pass.value, timeout: 3600 }, NULL_SID).then(function (res) { + if (res[0] === 6 || !res[1] || !res[1].ubus_rpc_session) { fail('Неверный логин или пароль'); return; } + var s = res[1].ubus_rpc_session; + var acl = res[1].acls && res[1].acls.ubus; + if (acl && !acl['zapret-manager'] && !acl['*']) { fail('У пользователя нет доступа к Zapret Manager'); return; } + return ubus('zapret-manager', 'status', {}, s).then(function (st) { + if (st[0] === 3 || st[0] === 4) { fail('rpcd-плагин zapret-manager не найден — переустановите панель'); return; } + sid = s; + L.env.sessionid = s; + sset(K_SID, s, remember.checked); + sset(K_USER, user.value.trim(), true); + closeLogin(); + startApp(); + }); + }).catch(function (e) { + fail(e && e.noUbus ? e.message : ('Роутер не отвечает: ' + ((e && e.message) || e))); + }); + }); + + loginEl = E('div', { 'class': 'zmw-login' }, [ + E('div', { 'class': 'zmw-orbs', 'aria-hidden': 'true' }, [ E('i'), E('i'), E('i') ]), + E('div', { 'class': 'zmw-grid-bg', 'aria-hidden': 'true' }), + card, + E('button', { 'type': 'button', 'class': 'zmw-icon-btn zmw-login-theme zmw-theme-toggle', 'click': toggleTheme }) + ]); + document.body.classList.add('zmw-locked'); + root.appendChild(loginEl); + applyTheme(); + setTimeout(function () { (user.value ? pass : user).focus(); }, 60); +} + +function closeLogin() { + if (!loginEl) return; + var el = loginEl; + loginEl = null; + authLostShown = false; + el.classList.add('zmw-leave'); + document.body.classList.remove('zmw-locked'); + setTimeout(function () { el.remove(); }, 380); +} + +function authLost() { + if (authLostShown || loginEl) return; + authLostShown = true; + sid = null; + sset(K_SID, null); + showLogin('Сессия истекла — войдите снова', 'warning'); +} + +function logout() { + var s = sid; + sid = null; + sset(K_SID, null); + var done = function () { + poll._reset(); + if (viewEl) viewEl.innerHTML = ''; + showLogin('Вы вышли из панели.', 'info'); + }; + if (!s) return done(); + ubus('session', 'destroy', {}, s).catch(function () {}).then(done); +} + +var started = false; +function startApp() { + if (!shell) buildShell(); + document.body.classList.add('zmw-ready'); + if (!started) { + started = true; + window.addEventListener('hashchange', function () { route(); }); + startShellPolling(); + } + loadShellInfo(); + refreshShellStatus(); + refreshMemory(); + route(); +} + +/* ───────────────────────── Старт ───────────────────────── */ + +function boot() { + root = document.getElementById('zmw-root'); + document.body.classList.add('zmw-body'); + applyTheme(); + + document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { closeDrawer(); } }); + + if (/[?&]logout=1/.test(location.search)) { + var s = sid; + sid = null; + sset(K_SID, null); + try { history.replaceState(null, '', '/' + location.hash); } catch (e) {} + if (s) ubus('session', 'destroy', {}, s).catch(function () {}); + showLogin('Вы вышли из панели. Войдите снова.', 'info'); + return; + } + + if (!sid) { showLogin(); return; } + + /* Проверяем, жива ли сохранённая сессия */ + ubus('session', 'access', { scope: 'ubus', object: 'zapret-manager', 'function': 'status' }, sid).then(function (res) { + if (res[0] === 0 && res[1] && res[1].access) startApp(); + else { sid = null; sset(K_SID, null); showLogin(); } + }).catch(function (e) { + sid = null; sset(K_SID, null); + showLogin(e && e.noUbus ? e.message : null, 'error'); + }); +} + +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); +else boot(); + +})(); +ZM_INSTALLER_EOF +cat > '/www/zm/app.css' << 'ZM_INSTALLER_EOF' +/* Zapret Manager — Web UI theme */ + +:root { + --font: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code", Consolas, "Liberation Mono", monospace; + + --a1: #7c5cff; + --a2: #3b82f6; + --a3: #22d3ee; + --grad: linear-gradient(135deg, var(--a1) 0%, var(--a2) 55%, var(--a3) 100%); + --grad-soft: linear-gradient(135deg, rgba(124,92,255,.16), rgba(34,211,238,.10)); + --ring: 0 0 0 3px rgba(124,92,255,.28); + + --radius: 18px; + --radius-sm: 12px; + --side-w: 272px; + + /* светлая тема */ + --bg: #f3f5fa; + --bg-glow-1: rgba(124,92,255,.12); + --bg-glow-2: rgba(34,211,238,.10); + --surface: rgba(255,255,255,.82); + --surface-solid: #ffffff; + --surface-2: #f5f6fb; + --surface-3: #eceff6; + --side-bg: rgba(255,255,255,.66); + --border: rgba(15,23,42,.08); + --border-2: rgba(15,23,42,.14); + --text: #0f172a; + --text-2: #334155; + --muted: #64748b; + --shadow: 0 1px 2px rgba(15,23,42,.04), 0 12px 32px -18px rgba(15,23,42,.18); + --shadow-lg: 0 24px 60px -24px rgba(15,23,42,.35); + --input-bg: #ffffff; + --console: #0b1020; + --console-border: rgba(15,23,42,.12); + + --ok: #059669; --ok-bg: rgba(16,185,129,.12); --ok-dot: #10b981; + --bad: #dc2626; --bad-bg: rgba(239,68,68,.10); --bad-dot: #ef4444; + --warn: #b45309; --warn-bg: rgba(245,158,11,.14); --warn-dot: #f59e0b; + --off: #64748b; --off-bg: rgba(100,116,139,.12); + + /* для detectMissingThemeVar() в страницах LuCI */ + --background-color-medium: var(--surface-solid); + --background-color-low: var(--surface-2); + color-scheme: light; +} + +html[data-theme="dark"] { + --bg: #07090f; + --bg-glow-1: rgba(124,92,255,.20); + --bg-glow-2: rgba(34,211,238,.10); + --surface: rgba(22,27,38,.72); + --surface-solid: #121722; + --surface-2: rgba(255,255,255,.045); + --surface-3: rgba(255,255,255,.08); + --side-bg: rgba(12,15,23,.72); + --border: rgba(255,255,255,.07); + --border-2: rgba(255,255,255,.13); + --text: #e8ebf4; + --text-2: #c3c9d8; + --muted: #8a93a8; + --shadow: 0 1px 0 rgba(255,255,255,.03) inset, 0 16px 40px -22px rgba(0,0,0,.8); + --shadow-lg: 0 30px 80px -30px rgba(0,0,0,.9); + --input-bg: rgba(255,255,255,.04); + --console: #070a12; + --console-border: rgba(255,255,255,.08); + + --ok: #34d399; --ok-bg: rgba(52,211,153,.12); --ok-dot: #34d399; + --bad: #f87171; --bad-bg: rgba(248,113,113,.12); --bad-dot: #f87171; + --warn: #fbbf24; --warn-bg: rgba(251,191,36,.12); --warn-dot: #fbbf24; + --off: #94a3b8; --off-bg: rgba(148,163,184,.12); + color-scheme: dark; +} + +*, *::before, *::after { box-sizing: border-box; } + +html, body { margin: 0; padding: 0; } +body.zmw-body { + font-family: var(--font); + font-size: 14px; + line-height: 1.5; + color: var(--text); + background-color: var(--bg); + background-image: + radial-gradient(900px 600px at -10% -20%, var(--bg-glow-1), transparent 60%), + radial-gradient(800px 520px at 110% -10%, var(--bg-glow-2), transparent 60%); + background-attachment: fixed; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + min-height: 100vh; + overflow-x: hidden; +} +body.zmw-locked { overflow: hidden; } +html.zmw-theme-anim body, html.zmw-theme-anim .zmw-side, html.zmw-theme-anim .zm-card, +html.zmw-theme-anim .zmw-top, html.zmw-theme-anim .cbi-button { transition: background-color .35s, color .35s, border-color .35s !important; } + +a { color: inherit; } +::selection { background: rgba(124,92,255,.35); } + +* { scrollbar-width: thin; scrollbar-color: var(--border-2) transparent; } +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 10px; border: 2px solid transparent; background-clip: content-box; } +::-webkit-scrollbar-track { background: transparent; } + +.zmw-i { width: 20px; height: 20px; flex-shrink: 0; display: block; } + +/* ───────────── Логотип ───────────── */ + +.zmw-logo { + width: 42px; height: 42px; border-radius: 13px; + background: + radial-gradient(120% 90% at 30% 0%, rgba(56,189,248,.35), transparent 60%), + linear-gradient(160deg, #13223f 0%, #0a1426 55%, #060c18 100%); + display: grid; place-items: center; flex-shrink: 0; + box-shadow: 0 8px 22px -8px rgba(14,165,233,.75), inset 0 1px 0 rgba(255,255,255,.14), inset 0 0 0 1px rgba(56,189,248,.28); + position: relative; + overflow: visible; +} +.zmw-logo svg { width: 32px; height: 32px; filter: drop-shadow(0 0 6px rgba(56,189,248,.55)) drop-shadow(0 2px 2px rgba(0,0,0,.45)); } +.zmw-logo-lg { width: 76px; height: 76px; border-radius: 22px; margin: 0 auto 18px; } +.zmw-logo-lg svg { width: 58px; height: 58px; animation: zmw-zap 4.5s ease-in-out infinite; } +.zmw-logo-lg::after { + content: ""; position: absolute; inset: -12px; border-radius: 30px; + background: radial-gradient(circle, rgba(14,165,233,.55), rgba(124,92,255,.25) 60%, transparent 75%); + filter: blur(18px); z-index: -1; +} +@keyframes zmw-zap { + 0%, 86%, 100% { filter: drop-shadow(0 0 8px rgba(56,189,248,.55)) drop-shadow(0 2px 2px rgba(0,0,0,.45)); } + 90% { filter: drop-shadow(0 0 18px rgba(125,211,252,1)) drop-shadow(0 0 4px #fff); } + 94% { filter: drop-shadow(0 0 6px rgba(56,189,248,.45)) drop-shadow(0 2px 2px rgba(0,0,0,.45)); } +} + +/* ───────────── Кнопки-иконки ───────────── */ + +.zmw-icon-btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + height: 40px; min-width: 40px; padding: 0 10px; + border-radius: 12px; border: 1px solid var(--border); + background: var(--surface); color: var(--text-2); + cursor: pointer; font: 600 13px/1 var(--font); + transition: background .15s, border-color .15s, color .15s, transform .1s; + backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); +} +.zmw-icon-btn:hover { border-color: var(--border-2); color: var(--text); background: var(--surface-solid); } +.zmw-icon-btn:active { transform: scale(.96); } +.zmw-icon-btn:focus-visible { outline: none; box-shadow: var(--ring); } +.zmw-icon-btn .zmw-i { width: 19px; height: 19px; } +.zmw-spin .zmw-i { animation: zmw-spin .7s cubic-bezier(.4,0,.2,1); } +@keyframes zmw-spin { to { transform: rotate(360deg); } } + +/* ───────────── Оболочка ───────────── */ + +.zmw-shell { display: flex; min-height: 100vh; opacity: 0; transition: opacity .35s; } +body.zmw-ready .zmw-shell { opacity: 1; } +body.zmw-locked .zmw-shell { filter: blur(6px); pointer-events: none; } + +.zmw-side { + position: sticky; top: 0; height: 100vh; + width: var(--side-w); flex-shrink: 0; + display: flex; flex-direction: column; + padding: 20px 14px 16px; + background: var(--side-bg); + border-right: 1px solid var(--border); + backdrop-filter: blur(18px) saturate(140%); -webkit-backdrop-filter: blur(18px) saturate(140%); + z-index: 40; + overflow-y: auto; +} + +.zmw-brand { display: flex; align-items: center; gap: 12px; padding: 2px 8px 14px; } +.zmw-brand-text { min-width: 0; flex: 1; } +.zmw-brand-name { font-weight: 750; font-size: 16px; letter-spacing: -.01em; } +.zmw-brand-sub { font-size: 12px; color: var(--muted); margin-top: 1px; } +.zmw-drawer-close { display: none; } + +.zmw-update { + display: block; margin: 0 6px 12px; padding: 9px 12px; + border-radius: 12px; font-size: 12.5px; font-weight: 600; text-decoration: none; + color: var(--text); background: var(--grad-soft); border: 1px solid rgba(124,92,255,.35); +} +.zmw-update::before { content: "✦ "; color: var(--a1); } +.zmw-update[hidden] { display: none; } + +.zmw-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; } +.zmw-nav-group { + font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; + color: var(--muted); opacity: .8; padding: 12px 12px 4px; +} +.zmw-nav-group:first-child { padding-top: 4px; } +.zmw-nav-item { + position: relative; + display: flex; align-items: center; gap: 12px; + padding: 6px 12px; border-radius: 12px; + text-decoration: none; color: var(--text-2); + font-weight: 550; font-size: 14px; + transition: background .15s, color .15s; +} +.zmw-nav-item:hover { background: var(--surface-2); color: var(--text); } +.zmw-nav-ico { + width: 30px; height: 30px; border-radius: 10px; + display: grid; place-items: center; flex-shrink: 0; + background: var(--surface-2); border: 1px solid var(--border); + color: var(--muted); + transition: background .2s, color .2s, border-color .2s, box-shadow .2s; +} +.zmw-nav-ico .zmw-i { width: 18px; height: 18px; } +.zmw-nav-label { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.zmw-nav-item.zmw-active { background: var(--grad-soft); color: var(--text); } +.zmw-nav-item.zmw-active::before { + content: ""; position: absolute; left: -14px; top: 10px; bottom: 10px; width: 4px; + border-radius: 0 4px 4px 0; background: var(--grad); +} +.zmw-nav-item.zmw-active .zmw-nav-ico { + background: var(--grad); color: #fff; border-color: transparent; + box-shadow: 0 6px 16px -6px rgba(99,102,241,.8); +} +.zmw-nav-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; display: none; } +.zmw-nav-dot.zmw-on, .zmw-nav-dot.zmw-stop { display: block; } +.zmw-nav-dot.zmw-on { background: var(--ok-dot); box-shadow: 0 0 0 3px var(--ok-bg), 0 0 10px var(--ok-dot); } +.zmw-nav-dot.zmw-stop { background: var(--bad-dot); box-shadow: 0 0 0 3px var(--bad-bg), 0 0 10px var(--bad-dot); animation: zmw-blink 1.6s ease-in-out infinite; } +@keyframes zmw-blink { 50% { opacity: .45; } } + +.zmw-side-foot { display: flex; flex-direction: column; gap: 9px; padding: 14px 6px 0; margin-top: 12px; border-top: 1px solid var(--border); } +.zmw-pill { + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; border-radius: 12px; text-decoration: none; + font-weight: 650; font-size: 13px; + border: 1px solid var(--border); + background: var(--surface-2); +} +.zmw-pill-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; background: var(--off); } +.zmw-pill-ok { color: var(--ok); background: var(--ok-bg); border-color: transparent; } +.zmw-pill-ok .zmw-pill-dot { background: var(--ok-dot); animation: zmw-pulse 2.2s infinite; } +.zmw-pill-bad { color: var(--bad); background: var(--bad-bg); border-color: transparent; } +.zmw-pill-bad .zmw-pill-dot { background: var(--bad-dot); } +.zmw-pill-off { color: var(--muted); } +@keyframes zmw-pulse { + 0% { box-shadow: 0 0 0 0 rgba(16,185,129,.55); } + 70% { box-shadow: 0 0 0 8px rgba(16,185,129,0); } + 100% { box-shadow: 0 0 0 0 rgba(16,185,129,0); } +} +.zmw-device { padding: 2px 6px; } +.zmw-device-model { font-size: 12.5px; line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 650; color: var(--text); overflow-wrap: anywhere; } +.zmw-device-sub { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.zmw-side-links { display: flex; gap: 6px; } +.zmw-side-links a { + flex: 1; padding: 8px 10px !important; display: inline-flex; align-items: center; justify-content: center; gap: 6px; + padding: 7px 8px; border-radius: 10px; border: 1px solid var(--border); + font-size: 12.5px; font-weight: 600; text-decoration: none; color: var(--text-2); + transition: background .15s, color .15s; +} +.zmw-side-links a:hover { background: var(--surface-2); color: var(--text); } +.zmw-side-links .zmw-i { width: 15px; height: 15px; } +.zmw-mem { display: flex; flex-direction: column; gap: 9px; padding: 2px 6px; } +.zmw-mem:empty { display: none; } +.zmw-mem-head { display: flex; justify-content: space-between; gap: 8px; font-size: 11.5px; margin-bottom: 4px; } +.zmw-mem-label { color: var(--muted); font-weight: 600; } +.zmw-mem-val { color: var(--text-2); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; } +.zmw-mem-bar { height: 6px; border-radius: 6px; background: var(--surface-3); overflow: hidden; } +.zmw-mem-bar i { display: block; height: 100%; width: 0; border-radius: 6px; background: var(--grad); transition: width .6s cubic-bezier(.2,.8,.2,1); } +.zmw-mem-mid .zmw-mem-bar i { background: linear-gradient(90deg, #f59e0b, #fbbf24); } +.zmw-mem-hi .zmw-mem-bar i { background: linear-gradient(90deg, #ef4444, #f87171); } +.zmw-credit { font-size: 11.5px; color: var(--muted); text-align: center; opacity: .7; } + +.zmw-main { flex: 1; min-width: 0; display: flex; flex-direction: column; } + +.zmw-top { + position: sticky; top: 0; z-index: 30; + display: flex; align-items: center; gap: 14px; + padding: 18px 32px; + background: linear-gradient(to bottom, var(--bg) 30%, transparent); +} +html[data-theme="dark"] .zmw-top { background: linear-gradient(to bottom, rgba(7,9,15,.92) 35%, rgba(7,9,15,0)); } +html[data-theme="light"] .zmw-top { background: linear-gradient(to bottom, rgba(243,245,250,.94) 35%, rgba(243,245,250,0)); } +.zmw-burger { display: none; } +.zmw-titles { flex: 1; min-width: 0; } +.zmw-title { margin: 0; font-size: 24px; font-weight: 750; letter-spacing: -.02em; line-height: 1.2; } +.zmw-sub { font-size: 13px; color: var(--muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.zmw-top-actions { display: flex; gap: 8px; align-items: center; } +.zmw-logout span { padding-right: 2px; } +.zmw-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.zmw-link-btn { text-decoration: none; padding: 0 13px; } +.zmw-link-btn .zmw-i { width: 18px; height: 18px; } +.zmw-lbl-short { display: none; } +.zmw-link-kvn { + color: #fff; border-color: transparent; + background: var(--grad); background-size: 160% 100%; + box-shadow: 0 8px 20px -10px rgba(99,102,241,.9), inset 0 1px 0 rgba(255,255,255,.22); + transition: background-position .35s, transform .1s, box-shadow .2s; +} +.zmw-link-kvn:hover { color: #fff; border-color: transparent; background: var(--grad); background-size: 160% 100%; background-position: 100% 0; box-shadow: 0 10px 24px -10px rgba(99,102,241,1), inset 0 1px 0 rgba(255,255,255,.22); } +.zmw-link-tg { color: #229ed9; border-color: rgba(34,158,217,.35); background: rgba(34,158,217,.10); } +.zmw-link-tg:hover { color: #229ed9; border-color: rgba(34,158,217,.6); background: rgba(34,158,217,.16); } +html[data-theme="dark"] .zmw-link-tg, html[data-theme="dark"] .zmw-link-tg:hover { color: #5cc1f0; } +.zmw-top-sep { width: 1px; height: 24px; background: var(--border-2); margin: 0 4px; } +#zmw-view .zm-header-links { display: none; } + +.zmw-view { padding: 6px 32px 24px; flex: 1; width: 100%; max-width: 1320px; } +.zmw-foot { padding: 8px 32px 24px; font-size: 12px; color: var(--muted); opacity: .6; } + +.zmw-page { animation: zmw-page-in .42s cubic-bezier(.2,.8,.2,1) both; } +@keyframes zmw-page-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } + +.zmw-scrim { display: none; } + +/* ───────────── Скелетон / ошибка ───────────── */ + +.zmw-skel-wrap { display: flex; flex-direction: column; gap: 16px; } +.zmw-skel-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; } +.zmw-skel { + border-radius: var(--radius); + background: linear-gradient(90deg, var(--surface-2) 0%, var(--surface-3) 40%, var(--surface-2) 80%); + background-size: 300% 100%; + animation: zmw-shimmer 1.4s infinite linear; + border: 1px solid var(--border); +} +.zmw-skel-bar { height: 64px; } +.zmw-skel-card { height: 170px; } +.zmw-skel-wide { height: 240px; } +@keyframes zmw-shimmer { from { background-position: 100% 0; } to { background-position: -200% 0; } } + +.zmw-error { + display: flex; gap: 16px; align-items: flex-start; + padding: 22px 24px; border-radius: var(--radius); + background: var(--surface); border: 1px solid rgba(239,68,68,.3); + box-shadow: var(--shadow); +} +.zmw-error-ico { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; background: var(--bad-bg); color: var(--bad); flex-shrink: 0; } +.zmw-error h3 { margin: 0 0 6px; font-size: 16px; } +.zmw-error p { margin: 0 0 14px; color: var(--muted); overflow-wrap: anywhere; } + +/* ───────────── Вход ───────────── */ + +.zmw-login { + position: fixed; inset: 0; z-index: 200; + display: grid; place-items: center; padding: 20px; + background: var(--bg); + overflow: auto; + animation: zmw-fade .4s ease both; +} +.zmw-login.zmw-leave { animation: zmw-fade-out .38s ease both; } +@keyframes zmw-fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes zmw-fade-out { to { opacity: 0; transform: scale(1.02); } } + +.zmw-orbs { position: absolute; inset: 0; overflow: hidden; pointer-events: none; } +.zmw-orbs i { position: absolute; border-radius: 50%; filter: blur(70px); opacity: .55; } +.zmw-orbs i:nth-child(1) { width: 520px; height: 520px; background: #7c5cff; left: -140px; top: -160px; animation: zmw-float1 18s ease-in-out infinite alternate; } +.zmw-orbs i:nth-child(2) { width: 460px; height: 460px; background: #22d3ee; right: -140px; bottom: -160px; opacity: .38; animation: zmw-float2 22s ease-in-out infinite alternate; } +.zmw-orbs i:nth-child(3) { width: 320px; height: 320px; background: #3b82f6; right: 18%; top: 12%; opacity: .25; animation: zmw-float1 26s ease-in-out infinite alternate-reverse; } +html[data-theme="light"] .zmw-orbs i { opacity: .28; } +@keyframes zmw-float1 { to { transform: translate(120px, 80px) scale(1.1); } } +@keyframes zmw-float2 { to { transform: translate(-100px, -60px) scale(1.15); } } + +.zmw-grid-bg { + position: absolute; inset: 0; pointer-events: none; + background-image: + linear-gradient(var(--border) 1px, transparent 1px), + linear-gradient(90deg, var(--border) 1px, transparent 1px); + background-size: 44px 44px; + -webkit-mask-image: radial-gradient(ellipse at center, #000 20%, transparent 70%); + mask-image: radial-gradient(ellipse at center, #000 20%, transparent 70%); + opacity: .7; +} + +.zmw-login-card { + position: relative; z-index: 1; + width: 100%; max-width: 400px; + padding: 36px 32px 28px; + border-radius: 26px; + background: var(--surface); + border: 1px solid var(--border-2); + box-shadow: var(--shadow-lg); + backdrop-filter: blur(24px) saturate(150%); -webkit-backdrop-filter: blur(24px) saturate(150%); + text-align: center; + animation: zmw-card-in .6s cubic-bezier(.2,.8,.2,1) both; +} +@keyframes zmw-card-in { from { opacity: 0; transform: translateY(18px) scale(.98); } to { opacity: 1; transform: none; } } +.zmw-login-card h1 { + margin: 0; font-size: 26px; font-weight: 800; letter-spacing: -.02em; + background: linear-gradient(135deg, var(--text) 30%, var(--a1) 75%, var(--a3)); + -webkit-background-clip: text; background-clip: text; color: transparent; +} +.zmw-login-sub { margin: 6px 0 24px; color: var(--muted); font-size: 13.5px; } +.zmw-login-sub b { color: var(--text-2); font-weight: 650; } +.zmw-shake { animation: zmw-shake .45s cubic-bezier(.36,.07,.19,.97) both; } +@keyframes zmw-shake { 10%,90% { transform: translateX(-1px); } 20%,80% { transform: translateX(3px); } 30%,50%,70% { transform: translateX(-6px); } 40%,60% { transform: translateX(6px); } } + +.zmw-field { display: block; text-align: left; margin-bottom: 14px; } +.zmw-field-label { display: block; font-size: 12.5px; font-weight: 650; color: var(--text-2); margin: 0 0 6px 2px; } +.zmw-input { + display: flex; align-items: center; gap: 10px; + height: 48px; padding: 0 6px 0 14px; + border-radius: 14px; border: 1px solid var(--border-2); + background: var(--input-bg); + color: var(--muted); + transition: border-color .15s, box-shadow .15s; +} +.zmw-input:focus-within { border-color: var(--a1); box-shadow: var(--ring); color: var(--a1); } +.zmw-input .zmw-i { width: 18px; height: 18px; } +.zmw-input input { + flex: 1; min-width: 0; height: 100%; + border: 0; outline: 0; background: transparent; + color: var(--text); font: 500 15px/1 var(--font); +} +.zmw-input input::placeholder { color: var(--muted); opacity: .6; } +.zmw-eye { border: 0; background: transparent; color: var(--muted); width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; cursor: pointer; } +.zmw-eye:hover { background: var(--surface-2); color: var(--text); } + +.zmw-check { display: flex; align-items: center; gap: 10px; margin: 4px 2px 16px; font-size: 13px; color: var(--text-2); cursor: pointer; text-align: left; user-select: none; } +.zmw-check input { position: absolute; opacity: 0; pointer-events: none; } +.zmw-check-box { + width: 20px; height: 20px; border-radius: 7px; flex-shrink: 0; + border: 1.5px solid var(--border-2); background: var(--input-bg); + display: grid; place-items: center; transition: background .15s, border-color .15s; +} +.zmw-check input:checked + .zmw-check-box { background: var(--grad); border-color: transparent; } +.zmw-check input:checked + .zmw-check-box::after { content: ""; width: 10px; height: 5px; border: 2px solid #fff; border-top: 0; border-right: 0; transform: rotate(-45deg) translate(1px, -1px); } +.zmw-check input:focus-visible + .zmw-check-box { box-shadow: var(--ring); } + +.zmw-login-msg { display: none; text-align: left; font-size: 13px; font-weight: 550; padding: 10px 12px; border-radius: 12px; margin-bottom: 14px; } +.zmw-login-msg.zmw-show { display: block; } +.zmw-login-msg.zmw-error { background: var(--bad-bg); color: var(--bad); } +.zmw-login-msg.zmw-warning { background: var(--warn-bg); color: var(--warn); } +.zmw-login-msg.zmw-info { background: var(--grad-soft); color: var(--text-2); } + +.zmw-btn-primary { + position: relative; width: 100%; height: 50px; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + border: 0; border-radius: 14px; cursor: pointer; + background: var(--grad); background-size: 160% 100%; background-position: 0 0; + color: #fff; font: 700 15px/1 var(--font); letter-spacing: .01em; + box-shadow: 0 12px 28px -10px rgba(99,102,241,.8), inset 0 1px 0 rgba(255,255,255,.25); + transition: background-position .35s, transform .1s, box-shadow .2s; +} +.zmw-btn-primary:hover { background-position: 100% 0; box-shadow: 0 16px 32px -10px rgba(99,102,241,.9), inset 0 1px 0 rgba(255,255,255,.25); } +.zmw-btn-primary:active { transform: scale(.985); } +.zmw-btn-primary:focus-visible { outline: none; box-shadow: var(--ring), 0 12px 28px -10px rgba(99,102,241,.8); } +.zmw-btn-primary .zmw-i { width: 18px; height: 18px; transition: transform .2s; } +.zmw-btn-primary:hover .zmw-i { transform: translateX(3px); } +.zmw-btn-primary.zmw-loading span, .zmw-btn-primary.zmw-loading .zmw-i { opacity: 0; } +.zmw-btn-primary.zmw-loading::after { + content: ""; position: absolute; width: 20px; height: 20px; border-radius: 50%; + border: 2.5px solid rgba(255,255,255,.35); border-top-color: #fff; + animation: zmw-spin .7s linear infinite; +} +.zmw-login-foot { margin-top: 18px; font-size: 12.5px; color: var(--muted); } +.zmw-login-theme { position: absolute; top: 18px; right: 18px; z-index: 2; } + +/* ───────────── Модальное окно ───────────── */ + +.zmw-modal-wrap { position: fixed; inset: 0; z-index: 300; display: grid; place-items: center; padding: 20px; background: rgba(5,7,12,.55); backdrop-filter: blur(6px); opacity: 0; transition: opacity .2s; } +.zmw-modal-wrap.zmw-in { opacity: 1; } +.zmw-modal { width: 100%; max-width: 560px; max-height: 85vh; overflow: auto; padding: 24px; border-radius: 20px; background: var(--surface-solid); border: 1px solid var(--border-2); box-shadow: var(--shadow-lg); } +.zmw-modal h3 { margin: 0 0 12px; } + +/* ═════════════ Компоненты страниц (перекрывают style.css LuCI) ═════════════ */ + +#zmw-view .zm-wrap { max-width: none; gap: 18px; } +#zmw-view .zm-webui-link { display: none !important; } + +#zmw-view .zm-header { align-items: center; gap: 12px; margin: 0; padding: 20px 24px; border-radius: var(--radius); background: var(--grad-soft); border: 1px solid rgba(124,92,255,.22); position: relative; overflow: hidden; } +#zmw-view .zm-header::after { content: ""; position: absolute; right: -60px; top: -80px; width: 240px; height: 240px; border-radius: 50%; background: var(--grad); filter: blur(60px); opacity: .22; pointer-events: none; } +#zmw-view .zm-header h2 { font-size: 22px; font-weight: 800; letter-spacing: -.02em; background: linear-gradient(135deg, var(--text) 35%, var(--a1) 80%, var(--a3)); -webkit-background-clip: text; background-clip: text; color: transparent; } +#zmw-view .zm-header-by { opacity: 1; color: var(--muted); font-size: 13px; } +#zmw-view .zm-header-links { position: relative; z-index: 1; } +#zmw-view .zm-header-links a { + color: var(--text); background: var(--surface); border: 1px solid var(--border-2); + padding: 7px 14px; font-size: 12.5px; font-weight: 650; border-radius: 999px; + backdrop-filter: blur(8px); + transition: border-color .15s, transform .15s, background .15s; +} +#zmw-view .zm-header-links a:hover { border-color: var(--a1); transform: translateY(-1px); background: var(--surface-solid); } + +#zmw-view :not(.zm-cards) > .zm-card + .zm-current-banner, +#zmw-view .zm-current-banner + .zm-card, +#zmw-view :not(.zm-cards) > .zm-card + .zm-log, +#zmw-view .zm-card + .zm-refresh-banner, +#zmw-view .zm-card + .zm-cards, +#zmw-view .zm-cards + .zm-card { margin-top: 18px; } +#zmw-view .zm-wrap > * + * { margin-top: 0 !important; } +#zmw-view :not(.zm-cards):not(.bt-cols):not(.zm-wrap) > .zm-card:not(:last-child) { margin-bottom: 18px; } +#zmw-view .zm-current-banner { margin-bottom: 0; } + +#zmw-view .zm-cards { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px; } + +#zmw-view .zm-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 22px 24px; + box-shadow: var(--shadow); + backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); + color: var(--text); + transition: border-color .2s, box-shadow .2s; +} +#zmw-view .zm-card:hover { border-color: var(--border-2); box-shadow: var(--shadow); } +#zmw-view .zm-card h3 { margin: 0 0 14px; font-size: 16px; font-weight: 700; letter-spacing: -.01em; color: var(--text); } +#zmw-view .zm-card h3::before { + content: ""; width: 4px; height: 18px; border-radius: 4px; background: var(--grad); flex-shrink: 0; +} +#zmw-view .zm-card h4 { color: var(--text); } +#zmw-view .zm-card p { color: var(--text-2); } + +#zmw-view .zm-row { font-size: 13.5px; margin: 9px 0; gap: 12px; } +#zmw-view .zm-row .zm-label { opacity: 1; color: var(--muted); } +#zmw-view .bt-cols { column-gap: 48px; } +#zmw-view .bt-col .zm-row { padding: 6px 0; margin: 0; border-bottom: 1px dashed var(--border); } +#zmw-view .bt-col .zm-row:last-child { border-bottom: 0; } + +#zmw-view .zm-hint, #zmw-view p.zm-hint { opacity: 1; color: var(--muted); font-size: 12.5px; line-height: 1.6; } +#zmw-view a:not(.cbi-button) { color: var(--a2); text-decoration-color: rgba(59,130,246,.35); text-underline-offset: 3px; } +html[data-theme="dark"] #zmw-view a:not(.cbi-button) { color: #8ab4ff; } + +/* бейджи */ +#zmw-view .zm-badge { padding: 4px 11px 4px 9px; font-size: 12px; font-weight: 650; gap: 7px; letter-spacing: .01em; } +#zmw-view .zm-ok { background: var(--ok-bg); color: var(--ok); } +#zmw-view .zm-ok .zm-dot { background: var(--ok-dot); box-shadow: 0 0 8px var(--ok-dot); animation: zmw-pulse 2.4s infinite; } +#zmw-view .zm-bad { background: var(--bad-bg); color: var(--bad); } +#zmw-view .zm-bad .zm-dot { background: var(--bad-dot); } +#zmw-view .zm-warn { background: var(--warn-bg); color: var(--warn); } +#zmw-view .zm-warn .zm-dot { background: var(--warn-dot); } +#zmw-view .zm-off { background: var(--off-bg); color: var(--off); } +#zmw-view .zm-off .zm-dot { background: var(--off); } + +/* кнопки */ +#zmw-view .cbi-button, .zmw-modal .cbi-button, .zm-refresh-banner .cbi-button { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + min-height: 38px; height: auto; padding: 8px 16px; margin: 0; + border-radius: 12px; border: 1px solid var(--border-2); + background: var(--surface-solid); color: var(--text); + font: 600 13.5px/1.25 var(--font); letter-spacing: .005em; + text-decoration: none; white-space: normal; text-align: center; + cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.06); + transition: border-color .15s, background .15s, transform .1s, box-shadow .15s, color .15s; + -webkit-appearance: none; appearance: none; +} +html[data-theme="dark"] #zmw-view .cbi-button { background: rgba(255,255,255,.05); } +#zmw-view .cbi-button:hover { border-color: rgba(124,92,255,.55); background: var(--surface-solid); transform: translateY(-1px); box-shadow: 0 6px 16px -10px rgba(99,102,241,.6); } +html[data-theme="dark"] #zmw-view .cbi-button:hover { background: rgba(255,255,255,.08); } +#zmw-view .cbi-button:active { transform: translateY(0) scale(.98); } +#zmw-view .cbi-button:focus-visible { outline: none; box-shadow: var(--ring); } +#zmw-view .cbi-button[disabled], #zmw-view .cbi-button:disabled { opacity: .5; cursor: not-allowed; transform: none; box-shadow: none; } + +#zmw-view .cbi-button-positive, .zm-refresh-banner .cbi-button-positive, +html[data-theme="dark"] #zmw-view .cbi-button-positive { + background: var(--grad); background-size: 150% 100%; color: #fff; border-color: transparent; + box-shadow: 0 8px 20px -10px rgba(99,102,241,.9), inset 0 1px 0 rgba(255,255,255,.22); +} +#zmw-view .cbi-button-positive:hover, html[data-theme="dark"] #zmw-view .cbi-button-positive:hover { + background: var(--grad); background-size: 150% 100%; background-position: 100% 0; + border-color: transparent; color: #fff; + box-shadow: 0 12px 26px -10px rgba(99,102,241,1), inset 0 1px 0 rgba(255,255,255,.22); +} +#zmw-view .cbi-button-remove, html[data-theme="dark"] #zmw-view .cbi-button-remove { + background: var(--bad-bg); color: var(--bad); border-color: rgba(239,68,68,.28); box-shadow: none; +} +#zmw-view .cbi-button-remove:hover, html[data-theme="dark"] #zmw-view .cbi-button-remove:hover { + background: rgba(239,68,68,.18); border-color: rgba(239,68,68,.5); color: var(--bad); + box-shadow: 0 8px 20px -12px rgba(239,68,68,.8); +} + +#zmw-view .zm-actions { gap: 10px; margin: 16px 0; } + +/* вкладки внутри страниц — сегментированный переключатель */ +#zmw-view .zm-actions.zmw-tabs { + display: flex; flex-wrap: nowrap; gap: 4px; + padding: 5px; margin: 0 0 4px !important; + border-radius: 16px; background: var(--surface); border: 1px solid var(--border); + box-shadow: var(--shadow); + overflow-x: auto; scrollbar-width: none; + backdrop-filter: blur(14px); + width: max-content; max-width: 100%; +} +#zmw-view .zm-actions.zmw-tabs::-webkit-scrollbar { display: none; } +#zmw-view .zmw-tabs .cbi-button { + flex-shrink: 0; white-space: nowrap; + border: 0; background: transparent; box-shadow: none; color: var(--muted); + min-height: 36px; padding: 7px 16px; border-radius: 11px; +} +#zmw-view .zmw-tabs .cbi-button:hover { background: var(--surface-2); color: var(--text); transform: none; box-shadow: none; } +#zmw-view .zmw-tabs .cbi-button-positive, #zmw-view .zmw-tabs .cbi-button-positive:hover { + background: var(--grad); color: #fff; + box-shadow: 0 6px 16px -8px rgba(99,102,241,.9); +} + +/* поля ввода */ +#zmw-view .cbi-input-text, #zmw-view input[type="text"], #zmw-view input[type="number"], +#zmw-view input[type="password"], #zmw-view input[type="url"], #zmw-view select, #zmw-view .bt-input, +.zmw-modal input[type="text"], .zmw-modal select { + height: 40px; padding: 0 14px; + border-radius: 12px; border: 1px solid var(--border-2); + background: var(--input-bg); color: var(--text); + font: 500 14px/1 var(--font); + outline: none; box-shadow: none; + transition: border-color .15s, box-shadow .15s; + max-width: 100%; +} +#zmw-view select, .zmw-modal select { + -webkit-appearance: none; appearance: none; cursor: pointer; + padding-right: 38px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238a93a8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: right 12px center; background-size: 16px; +} +#zmw-view select option { background: var(--surface-solid); color: var(--text); } +#zmw-view .zm-hour-select { min-width: 120px; font-variant-numeric: tabular-nums; } +#zmw-view .zm-actions > .cbi-input-text[type="text"] { flex: 1 1 240px; min-width: 0; } +#zmw-view .cbi-input-text:focus, #zmw-view input:focus, #zmw-view select:focus, #zmw-view .bt-input:focus { border-color: var(--a1); box-shadow: var(--ring); } +#zmw-view input::placeholder { color: var(--muted); opacity: .7; } + +/* плитки */ +#zmw-view .zm-grid { gap: 10px; } +#zmw-view .zm-grid-devices { gap: 10px; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); } +#zmw-view .zm-tile { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 13px; + padding: 11px 16px; + font-size: 13.5px; font-weight: 550; color: var(--text-2); + transition: border-color .15s, background .15s, transform .12s, box-shadow .15s, color .15s; +} +html.zm-theme-dark #zmw-view .zm-tile:not(.zm-active):not(.zm-tile-off) { background: var(--surface-2); border-color: var(--border); } +#zmw-view .zm-tile:hover { border-color: rgba(124,92,255,.55); color: var(--text); transform: translateY(-2px); box-shadow: 0 10px 20px -14px rgba(99,102,241,.7); } +html.zm-theme-dark #zmw-view .zm-tile:not(.zm-active):not(.zm-tile-off):hover { border-color: rgba(124,92,255,.55); } +#zmw-view .zm-tile.zm-active { + background: var(--grad-soft); + border-color: rgba(124,92,255,.65); + color: var(--text); font-weight: 700; + box-shadow: 0 0 0 1px rgba(124,92,255,.45) inset, 0 10px 24px -14px rgba(99,102,241,.9); +} +#zmw-view .zm-tile.zm-active::before { content: "✓ "; color: var(--a1); font-weight: 800; } +html[data-theme="dark"] #zmw-view .zm-tile.zm-active::before { color: #a594ff; } +#zmw-view .zm-tile.zm-tile-off { background: var(--bad-bg); border-color: rgba(239,68,68,.35); color: var(--bad); } +#zmw-view .zm-tile.zm-tile-pending { opacity: .5; } + +/* консоль */ +#zmw-view .zm-log { + background: var(--console); + color: #dfe6f3; + border: 1px solid var(--console-border); + border-radius: 16px; + padding: 44px 20px 18px; + font: 13px/1.75 var(--mono); + box-shadow: inset 0 1px 0 rgba(255,255,255,.04), var(--shadow); + position: relative; + background-image: + radial-gradient(circle at 20px 20px, #ff5f57 5px, transparent 5.5px), + radial-gradient(circle at 38px 20px, #febc2e 5px, transparent 5.5px), + radial-gradient(circle at 56px 20px, #28c840 5px, transparent 5.5px), + linear-gradient(to bottom, rgba(255,255,255,.035) 0, rgba(255,255,255,.035) 40px, transparent 40px); + background-repeat: no-repeat; +} +#zmw-view .zm-log.bt-panel { padding-top: 16px; background-image: none; } +#zmw-view .zm-log:empty::before { color: #8b949e; } +#zmw-view .zm-log-arrow { color: #67e8f9; } +#zmw-view .zm-log-msg-info { color: #fcd34d; } +#zmw-view .zm-log-msg-ok { color: #4ade80; } +#zmw-view .zm-log-msg-error { color: #fb7185; } +#zmw-view .zm-log-msg-warn { color: #fdba74; } +#zmw-view .zm-log-code { color: #94a3b8; } + +#zmw-view .zm-config-editor { + background: var(--console); color: #e2e8f0; + border: 1px solid var(--console-border); border-radius: 16px; + padding: 16px 18px; font: 13px/1.65 var(--mono); + outline: none; + transition: border-color .15s, box-shadow .15s; +} +#zmw-view .zm-config-editor:focus { border-color: var(--a1); box-shadow: var(--ring); } + +/* баннеры */ +#zmw-view .zm-refresh-banner, .zm-refresh-banner { + background: var(--warn-bg); border: 1px solid rgba(245,158,11,.35); + color: var(--warn); border-radius: var(--radius); padding: 14px 18px; + font-size: 14px; font-weight: 600; margin-top: 0; +} +#zmw-view .zm-current-banner { + background: var(--grad-soft); border: 1px solid rgba(124,92,255,.28); + border-radius: 14px; padding: 12px 16px; font-size: 13.5px; color: var(--text); +} +#zmw-view .zm-current-banner.zm-current-empty { background: var(--surface-2); border-color: var(--border); color: var(--muted); } +#zmw-view .bt-current-cmd { background: var(--console); color: #86efac; border-radius: 12px; font-family: var(--mono); } + +/* Telegram */ +#zmw-view .zm-tg-link-card { background: var(--ok-bg); border: 1px solid rgba(16,185,129,.28); border-radius: 16px; } +#zmw-view .zm-tg-link-box { background: var(--console); color: #86efac; border-radius: 12px; font-family: var(--mono); font-size: 13.5px; } +#zmw-view .zm-tg-qr-box img { border-radius: 14px; box-shadow: var(--shadow); } + +/* авторы */ +#zmw-view .zm-credits-grid { gap: 12px; } +#zmw-view .zm-credit-tile, html.zm-theme-dark #zmw-view .zm-credit-tile { + background: var(--surface-2); border: 1px solid var(--border); border-radius: 14px; padding: 14px; + transition: border-color .15s, transform .15s; +} +#zmw-view .zm-credit-tile:hover { border-color: var(--border-2); transform: translateY(-1px); } +#zmw-view .zm-credit-tile.zm-credit-self { background: var(--grad-soft); border-color: rgba(124,92,255,.35); } +#zmw-view .zm-credit-author { color: var(--muted); opacity: 1; } + +/* ByeTube таблица */ +#zmw-view .bt-table td { border-top-color: rgba(255,255,255,.08); } +#zmw-view .bt-chip { border-radius: 7px; } + +/* MagiTrickle и прочие iframe */ +#zmw-view iframe { border: 1px solid var(--border-2) !important; border-radius: 16px !important; background: #fff; box-shadow: var(--shadow); } + +#zmw-view pre:not(.zm-log) { font-family: var(--mono); } +#zmw-view hr { border: 0; border-top: 1px solid var(--border); } + +/* ───────────── Тосты ───────────── */ + +.zmw-body #zm-toast-container { + top: auto; bottom: 24px; right: 24px; left: auto; + gap: 10px; max-width: 420px; width: calc(100% - 48px); + align-items: flex-end; + z-index: 400; + pointer-events: none; +} +.zmw-body .zm-toast { + pointer-events: auto; + align-items: center; gap: 12px; + width: 100%; + padding: 14px 16px 14px 14px; + border-radius: 16px; + border: 1px solid var(--border-2); border-left: 1px solid var(--border-2); + background: var(--surface-solid); + color: var(--text); + box-shadow: var(--shadow-lg); + font-size: 14px; font-weight: 550; line-height: 1.45; + opacity: 0; transform: translateY(16px) scale(.98); + transition: opacity .25s ease, transform .3s cubic-bezier(.2,.8,.2,1); +} +.zmw-body .zm-toast.zm-toast-show { opacity: 1; transform: none; } +.zmw-body .zm-toast-icon { + width: 30px; height: 30px; border-radius: 10px; + display: grid; place-items: center; flex-shrink: 0; + font-size: 14px; font-weight: 800; line-height: 1; +} +.zmw-body .zm-toast-info .zm-toast-icon { background: var(--ok-bg); color: var(--ok); } +.zmw-body .zm-toast-error .zm-toast-icon { background: var(--bad-bg); color: var(--bad); } +.zmw-body .zm-toast-warning .zm-toast-icon { background: var(--warn-bg); color: var(--warn); } + +/* ───────────── Адаптив ───────────── */ + +@media (max-width: 1360px) { + .zmw-lbl-full { display: none; } + .zmw-lbl-short { display: inline; } +} +@media (max-width: 1180px) { + .zmw-logout span { display: none; } +} +@media (max-width: 1080px) and (min-width: 961px), (max-width: 760px) { + .zmw-lbl-short { display: none; } + .zmw-link-btn { padding: 0 10px; } + .zmw-sub { display: none; } +} + +@media (max-width: 960px) { + :root { --side-w: min(86vw, 300px); } + .zmw-side { + position: fixed; left: 0; top: 0; bottom: 0; height: auto; + transform: translateX(-104%); + transition: transform .32s cubic-bezier(.2,.8,.2,1); + box-shadow: var(--shadow-lg); + background: var(--surface-solid); + } + body.zmw-drawer-open .zmw-side { transform: none; } + .zmw-drawer-close { display: inline-flex; } + .zmw-scrim { + display: block; position: fixed; inset: 0; z-index: 35; + background: rgba(5,7,12,.5); backdrop-filter: blur(3px); + opacity: 0; pointer-events: none; transition: opacity .3s; + } + body.zmw-drawer-open .zmw-scrim { opacity: 1; pointer-events: auto; } + body.zmw-drawer-open { overflow: hidden; } + .zmw-burger { display: inline-flex; } + .zmw-top { padding: 12px 16px; gap: 10px; } + .zmw-view { padding: 4px 16px 20px; } + .zmw-foot { padding: 4px 16px 20px; } + .zmw-title { font-size: 20px; } +} + +@media (max-width: 600px) { + .zmw-sub { display: none; } + .zmw-top-actions { gap: 5px; } + .zmw-top-sep { display: none; } + .zmw-icon-btn { height: 36px; min-width: 36px; padding: 0 8px; border-radius: 11px; } + .zmw-link-btn { padding: 0 8px; } + .zmw-top { gap: 8px; } + .zmw-title { font-size: 18px; } + #zmw-view .zm-card { padding: 18px 16px; border-radius: 16px; } + #zmw-view .zm-cards { grid-template-columns: 1fr; } + #zmw-view .zm-header { padding: 16px; } + #zmw-view .zm-header-links { margin-left: 0; } + #zmw-view .zm-actions .cbi-button { flex: 1 1 auto; } + #zmw-view .zmw-tabs .cbi-button { flex: 0 0 auto; } + #zmw-view .zm-actions.zmw-tabs { width: 100%; } + #zmw-view .zm-refresh-banner, .zm-refresh-banner { flex-direction: column; align-items: stretch; text-align: left; } + .zmw-brand-name { font-size: 15px; white-space: nowrap; } + .zmw-nav-item { padding: 7px 12px; } + .zmw-login-card { padding: 30px 20px 22px; border-radius: 22px; } + .zmw-body #zm-toast-container { left: 12px; right: 12px; bottom: 12px; width: auto; max-width: none; } + #zmw-view .zm-log { font-size: 12.5px; padding: 40px 14px 14px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } +} +ZM_INSTALLER_EOF +cat > '/www/zm-webui.html' << 'ZM_INSTALLER_EOF' + + + + + + + +Zapret Manager + + + + + + +
+ + + + +ZM_INSTALLER_EOF +ZMW_BUILD="$(date +%s)" +sed -i "s/__ZMW_BUILD__/$ZMW_BUILD/g" /www/zm/app.js /www/zm/app.css /www/zm-webui.html +chmod 0644 /www/zm/app.js /www/zm/app.css /www/zm-webui.html + +ZMW_RESTART=0 +# JSON-RPC /ubus для uhttpd — обычно уже стоит вместе с LuCI +if [ ! -e /usr/lib/uhttpd_ubus.so ]; then + echo -e "${CYAN}Устанавливаем ${NC}uhttpd-mod-ubus${CYAN} для Web UI${NC}" + $INSTALL uhttpd-mod-ubus >/dev/null 2>&1 || { $PM update >/dev/null 2>&1; $INSTALL uhttpd-mod-ubus >/dev/null 2>&1; } || true + ZMW_RESTART=1 +fi + +# Отдельный экземпляр uhttpd. Если секция уже есть — не трогаем (порт мог быть изменён вручную) +if ! uci -q get uhttpd.zmweb >/dev/null; then + uci set uhttpd.zmweb=uhttpd + uci add_list uhttpd.zmweb.listen_http='0.0.0.0:7788' + uci add_list uhttpd.zmweb.listen_http='[::]:7788' + uci set uhttpd.zmweb.home='/www' + uci set uhttpd.zmweb.index_page='zm-webui.html' + uci set uhttpd.zmweb.ubus_prefix='/ubus' + uci set uhttpd.zmweb.no_dirlists='1' + uci set uhttpd.zmweb.rfc1918_filter='1' + uci set uhttpd.zmweb.max_requests='12' + uci set uhttpd.zmweb.max_connections='100' + uci set uhttpd.zmweb.script_timeout='120' + uci set uhttpd.zmweb.network_timeout='30' + uci set uhttpd.zmweb.http_keepalive='20' + uci set uhttpd.zmweb.tcp_keepalive='1' + uci commit uhttpd + ZMW_RESTART=1 +fi +[ "$ZMW_RESTART" = "1" ] && { /etc/init.d/uhttpd restart >/dev/null 2>&1 || true; } + +ZMW_PORT="$(uci -q get uhttpd.zmweb.listen_http | tr ' ' '\n' | head -n1 | sed 's/.*://')" +ZMW_IP="$(uci -q get network.lan.ipaddr | cut -d/ -f1)" +[ -n "$ZMW_IP" ] || ZMW_IP="192.168.1.1" + +echo -e "Zapret Manager ${GREEN}для ${NC}LuCI ${GREEN}установлен!${NC}" +echo -e "\n${CYAN}Web UI: ${NC}http://${ZMW_IP}:${ZMW_PORT:-7788}/${NC}\n" +