diff --git a/.github/workflows/luci.yml b/.github/workflows/luci.yml
new file mode 100644
index 0000000..51e55b7
--- /dev/null
+++ b/.github/workflows/luci.yml
@@ -0,0 +1,42 @@
+name: LuCI Studio
+on:
+ push:
+ paths: ['luci/**', '.github/workflows/luci.yml']
+ pull_request:
+ paths: ['luci/**', '.github/workflows/luci.yml']
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ test-and-package:
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Install browser test dependencies
+ run: |
+ python -m pip install playwright==1.55.0
+ python -m playwright install --with-deps chromium
+ - name: Check syntax and package metadata
+ run: |
+ for file in $(find luci -name '*.js'); do node --check "$file"; done
+ for file in $(find luci -type f -path '*/root/*' ! -name '*.json' ! -path '*/etc/config/*'); do sh -n "$file"; done
+ python -c 'import json,pathlib; [json.loads(p.read_text()) for p in pathlib.Path("luci").rglob("*.json")]'
+ - name: Check backend, watchdog and browser contracts
+ run: |
+ python luci/tests/backend.py
+ python luci/tests/sentinel.py
+ python luci/tests/theme_restore.py
+ python luci/tests/browser.py
+ - name: Build candidate IPK and source archive
+ run: python luci/scripts/build.py
+ - uses: actions/upload-artifact@v4
+ with:
+ name: OpenWrtStudio-LuCI-v1.0.0-rc1
+ path: |
+ luci/dist/*
+ luci/artifacts/*.png
+ luci/README.md
+ luci/RELEASE_NOTES.md
diff --git a/luci/.gitignore b/luci/.gitignore
new file mode 100644
index 0000000..46c6b32
--- /dev/null
+++ b/luci/.gitignore
@@ -0,0 +1,3 @@
+dist/
+artifacts/
+__pycache__/
diff --git a/luci/README.md b/luci/README.md
new file mode 100644
index 0000000..c4fd84d
--- /dev/null
+++ b/luci/README.md
@@ -0,0 +1,125 @@
+# OpenWrt Studio для LuCI — 1.0.0-rc1
+
+Тема оформления роутера и приложение-компаньон. Работают непосредственно на OpenWrt через сессию LuCI: Windows, .NET, SSH-профили и внешний сервер для этого не нужны.
+
+Это **кандидат для проверки**, а не подтверждённый на оборудовании стабильный выпуск и не полный перенос настольного приложения. Оформление использует Bootstrap из LuCI OpenWrt 24.10, commit `0e6537981036ea221a73e497920192179e9f74c3`, Apache-2.0. Footstrap изучался как пример; его код не копировался. Код приложения Studio — MIT.
+
+## Что работает
+
+| Возможность Studio | Реализация LuCI |
+| --- | --- |
+| Обзор, память, загрузка, overlay, uptime | Собственные карточки, RPC-опрос каждые 25 секунд, очистка при ошибке связи |
+| Отчёт для поддержки | Экспорт модели, прошивки и ресурсов; без профилей, адресов клиентов, hostname, паролей и ключей |
+| Клиенты | IPv4 DHCP-аренды; статические адреса и ограничения — через штатные формы DHCP/Firewall |
+| Карта VPN и сервисов | Наличие init-скрипта и статус служб, запуск/остановка/перезапуск после подтверждения |
+| Диагностика | WAN, ICMP, локальный/публичный DNS, интерфейсы и маршруты; журналы и nftables — через штатные страницы |
+| Sentinel | Ручная диагностика и отдельный фоновый демон: классификация локального DNS, WAN, внешней сети и остановленного прокси; восстановление DNS/выбранного ядра после повторных сбоев с паузой |
+| Mihomo | HTTPS-подписка, два пресета, редактирование JSON/YAML, проверка ядром, резервная копия, применение |
+| Wi-Fi, WAN/LAN, DHCP, проброс портов, hostname, пароль, backup | Штатные конфигураторы LuCI в оформлении Studio |
+| WireGuard / AmneziaWG | Импорт .conf с DNS, несколькими peer, IPv6 endpoint и параметрами обфускации; отложенные изменения UCI и штатная форма интерфейсов |
+| Sing-box | Генератор VLESS Reality, Hysteria 2 и TUIC с TUN; проверка ядром перед заменой файла |
+| OpenVPN | Импорт клиентского .ovpn со встроенными сертификатами и ключами, создание секции UCI и запуск |
+| Tailscale / ZeroTier | Auth key, Exit Node, анонс подсетей и принятие маршрутов Tailscale; вступление в сеть ZeroTier |
+| PassWall 2 и другие службы | Запуск/остановка/перезапуск; настройка через дополнительно установленные LuCI-модули |
+| Планировщик | Системный редактор cron и примеры расписаний |
+| Пакеты / ForkOP | Системный каталог, предложение адреса ForkOP по архитектуре, штатный редактор источников |
+| USB | Штатная страница точек монтирования при наличии `luci-app-mount` |
+
+Не перенесены: расширенные экраны узлов/задержек ForkOP, точное воспроизведение всех правил настольного Mihomo, конфигуратор Argon и собственный редактор USB. Эти настройки можно выполнять через дополнительные LuCI-модули. Трей Windows и SSH-профили не имеют прямого аналога в интерфейсе на самом роутере. Ссылки на необязательные LuCI-модули могут вести на отсутствующую страницу, пока модуль не установлен. Новость должна называть этот выпуск кандидатом и перечислять его фактические возможности.
+
+## VPN и Sentinel
+
+В разделе «Конструкторы VPN» импорт `.conf` не перезаписывает существующий интерфейс. После импорта проверьте зону брандмауэра и маршрутизацию в штатной форме LuCI и примените изменения с откатом. Требуется установленный протокол WireGuard/AmneziaWG; неизвестные параметры файла отклоняются.
+
+Для Sing-box используется `/etc/sing-box/config.json`, проверка `sing-box check -c`, резервная копия `config.json.studio-backup` и служба `/etc/init.d/sing-box`. Генератор создаёт современный TUN-конфиг; его совместимость проверяет установленное ядро. Настройка DNS и дополнительных правил остаётся доступной в редакторе JSON.
+
+OpenVPN сохраняется в `/etc/openvpn/studio.conf` и секции `openvpn.studio`. Поддерживаются клиентские профили со встроенным ключевым материалом. Внешние файлы, сценарии, плагины и неизвестные директивы отклоняются. Эта проверка не подтверждает доступность VPN-сервера. Профили с логином/паролем и отдельными сертификатами настраивайте в `luci-app-openvpn`.
+
+Tailscale передаёт ключ через временный файл с правами 0600 и удаляет его после команды. Анонсы подсетей/Exit Node требуют разрешения администратора tailnet. Участник ZeroTier должен быть разрешён в контроллере сети.
+
+Sentinel **выключен по умолчанию**. Настройки хранятся в `/etc/config/studio`, журнал доступен через `logread -e studio-sentinel`. Он проверяет ICMP и DNS каждые 60 секунд, по умолчанию ждёт 3 последовательных сбоя и 300 секунд между восстановлениями. Сбой WAN/внешней сети сам по себе не вызывает перезапуск всей сети. Автовосстановление ограничено dnsmasq и явно выбранным ядром прокси.
+
+Горячие клавиши темы: Ctrl+1 — обзор, Ctrl+2 — клиенты, Ctrl+3 — диагностика, Ctrl+, — инструменты. Кнопка ◐ переключает светлую/тёмную палитру и запоминает выбор в браузере.
+
+## Установка на OpenWrt 24.10 с opkg
+
+Пакеты не устанавливались на реальный роутер в рамках этой работы. Сначала проверьте на тестовом устройстве с резервной копией и доступом по SSH. IPK рассчитаны на современные ucode-шаблоны LuCI OpenWrt 24.10. Для старого Lua LuCI и apk-прошивок эти IPK не предназначены.
+
+Скопируйте оба IPK и SHA256SUMS.txt из `dist/` на роутер, затем:
+
+```sh
+cd /tmp/studio-release
+sha256sum -c SHA256SUMS.txt --ignore-missing
+opkg update
+opkg install ./luci-theme-studio_1.0.0~rc1-1_all.ipk ./luci-app-studio_1.0.0~rc1-1_all.ipk
+/etc/init.d/rpcd restart
+```
+
+Если BusyBox `sha256sum` не поддерживает `--ignore-missing`, сравните `sha256sum *.ipk` с соответствующими строками SHA256SUMS.txt.
+
+Откройте **Система → Система → Язык и стиль**, выберите **OpenWrtStudio**, примените изменения и обновите страницу браузера. Установка регистрирует тему, но не переключает её принудительно. Раздел **OpenWrt Studio** появляется в основном меню. При необходимости:
+
+```sh
+uci set luci.main.mediaurlbase='/luci-static/studio'
+uci commit luci
+```
+
+Тема поддерживает светлую и тёмную палитры по настройке браузера/ОС. Приложение работает и с другой темой, но карточки Studio оформляются при выборе темы Studio.
+
+## Mihomo
+
+Поддерживается служба `/etc/init.d/mihomo`, использующая `/etc/mihomo/config.yaml`. Для OpenClash и других схем запуска автоматическое применение не предназначено. Нужны установленное ядро Mihomo, поддержка TUN и геобазы для соответствующих правил. Подписка должна возвращать узлы в формате Mihomo, не base64-список URI.
+
+Перед заменой конфигурация проверяется `mihomo -t`; предыдущий файл копируется в `/etc/mihomo/config.yaml.studio-backup`. Файлы с подпиской создаются с правами 0600. Пресет «RU напрямую» отправляет остальной внешний трафик в прокси и не обещает точный список заблокированных сайтов. Генератор не публикует контроллер Mihomo в LAN.
+
+Восстановление прежнего файла по SSH:
+
+```sh
+cp /etc/mihomo/config.yaml.studio-backup /etc/mihomo/config.yaml
+/etc/init.d/mihomo restart
+```
+
+## Права
+
+Чтение ресурсов разрешено ACL `luci-app-studio`. Запись отделена от чтения: только перечисленные действия над перечисленными сервисами, сохранение VPN в фиксированные пути и подключение mesh VPN. Импорт WireGuard требует права чтения/записи UCI network; настройки Sentinel — UCI studio. RPC не принимает произвольные команды или пути. Стандартные формы настроек используют собственные ACL LuCI. Наличие init-скрипта показывает наличие службы, а не полную совместимость протокола с прошивкой.
+
+## Сборка и проверка
+
+```sh
+python3 luci/scripts/build.py
+python3 luci/tests/backend.py
+python3 luci/tests/sentinel.py
+python3 luci/tests/theme_restore.py
+python3 luci/tests/browser.py
+```
+
+Сборка IPK использует Python 3 без сторонних библиотек. Тест обработчика требует `jq`; браузерный тест требует Python Playwright и Chromium в `/usr/bin/chromium`. Тесты используют фиктивные RPC-ответы и изолированные заглушки команд OpenWrt. Они не проверяют реальный rpcd/jshn, установку opkg, ucode-рендеринг и маршрутизацию на устройстве.
+
+Для сборки штатными средствами OpenWrt SDK с установленным feeds/luci скопируйте `luci-theme-studio` и `luci-app-studio` в каталог `package/`, затем:
+
+```sh
+make defconfig
+make package/luci-theme-studio/compile V=s
+make package/luci-app-studio/compile V=s
+```
+
+## Проверка на роутере с возвратом прежней темы
+
+После копирования IPK на тестовый OpenWrt 24.10 можно выполнить:
+
+```sh
+sh smoke-router.sh /tmp/luci-theme-studio_1.0.0~rc1-1_all.ipk /tmp/luci-app-studio_1.0.0~rc1-1_all.ipk
+```
+
+Сценарий `scripts/smoke-router.sh` проверяет отсутствие отложенных настроек LuCI, установку, регистрацию RPC и рендеринг страницы входа с темой Studio. Обработчик завершения восстанавливает и проверяет прежнее значение темы, включая случай ошибки. Пакеты остаются установленными. Сценарий не меняет VPN, WAN, Wi-Fi или firewall; он пока не выполнялся на реальном оборудовании.
+
+## Удаление и возврат темы
+
+```sh
+uci set luci.main.mediaurlbase='/luci-static/bootstrap'
+uci commit luci
+opkg remove luci-app-studio luci-theme-studio
+/etc/init.d/rpcd restart
+```
+
+Удаление темы также переключает выбранную Studio на Bootstrap. Сохранённые конфигурации роутера и Mihomo при удалении не очищаются.
diff --git a/luci/RELEASE_NOTES.md b/luci/RELEASE_NOTES.md
new file mode 100644
index 0000000..84ff3a0
--- /dev/null
+++ b/luci/RELEASE_NOTES.md
@@ -0,0 +1,20 @@
+# OpenWrt Studio LuCI v1.0.0-rc1
+
+Первый кандидат темы и приложения OpenWrt Studio для веб-интерфейса роутера. Это отдельный комплект для LuCI, а не обновление Windows-приложения v2.5.19.
+
+- Светлая/тёмная тема с адаптивными карточками и горячими клавишами.
+- Обзор загрузки, RAM, overlay, времени работы и DHCP-клиентов; отчёт для поддержки без адресов клиентов, паролей и ключей.
+- Карта возможностей, управление установленными службами и диагностика WAN/DNS/маршрутов.
+- Импорт WireGuard/AmneziaWG: несколько peer, DNS, IPv6 endpoint и параметры обфускации. Изменения проверяются и применяются через LuCI.
+- Конструктор Mihomo с подпиской и двумя пресетами; конфигурация проверяется ядром и сохраняется с резервной копией.
+- Генератор Sing-box для VLESS Reality, Hysteria 2 и TUIC с проверкой ядром.
+- Импорт клиентского OpenVPN со встроенными сертификатами; сценарии и плагины из импортируемого профиля не выполняются.
+- Подключение Tailscale (Auth key, Exit Node, подсети) и ZeroTier.
+- Фоновый Sentinel с повторными проверками и паузой между восстановлениями. По умолчанию выключен.
+- Доступ к штатным настройкам Wi-Fi, WAN/LAN, DHCP, брандмауэра, cron, пакетов, резервного копирования и USB; предложение источника ForkOP по архитектуре.
+
+IPK предназначены для проверки на OpenWrt 24.10 с современным LuCI и opkg. Для apk-прошивок используйте исходники и SDK своей версии. Пакеты протоколов и ядра VPN устанавливаются отдельно.
+
+Прошли проверки синтаксиса, браузерные сценарии на фиктивных RPC-ответах, изолированные проверки RPC-обработчика и Sentinel, сборка и проверка структуры IPK. На реальном роутере установка, работа ucode-шаблонов, протоколов и маршрутизации пока не проверены. Полная идентичность всех функций настольной программы не заявляется. Подробности установки и ограничения — в README комплекта.
+
+Файлы: `luci-theme-studio_1.0.0~rc1-1_all.ipk`, `luci-app-studio_1.0.0~rc1-1_all.ipk`, архив исходников и SHA256SUMS.txt.
diff --git a/luci/announcements/4pda.bbcode b/luci/announcements/4pda.bbcode
new file mode 100644
index 0000000..81d1c52
--- /dev/null
+++ b/luci/announcements/4pda.bbcode
@@ -0,0 +1,21 @@
+[b]OpenWrt Studio для LuCI — v1.0.0-rc1[/b]
+
+На предложение перенести интерфейс прямо в OpenWrt: вызов принят. Подготовлен комплект из темы и LuCI-приложения, работающего непосредственно на роутере без Windows/.NET.
+
+[spoiler="Возможности"]
+— Светлая/тёмная тема, адаптивная раскладка и горячие клавиши.
+— Обзор ресурсов и DHCP-клиентов, отчёт для поддержки без адресов клиентов и секретов.
+— Карта возможностей и управление установленными службами.
+— Импорт WireGuard/AmneziaWG (.conf, несколько peer, DNS, IPv6 endpoint, параметры обфускации).
+— Конструктор Mihomo с подпиской и проверкой ядром перед применением.
+— Генератор Sing-box: VLESS Reality / Hysteria 2 / TUIC.
+— Импорт клиентского OpenVPN со встроенными сертификатами.
+— Tailscale: Auth key, Exit Node, подсети; подключение ZeroTier.
+— Диагностика WAN/DNS/маршрутов и фоновый Sentinel с повторными проверками и паузой между восстановлениями.
+— Штатные настройки Wi-Fi, WAN/LAN, DHCP, firewall, cron, пакетов, резервного копирования и USB в оформлении Studio.
+[/spoiler]
+
+[b]Статус:[/b] кандидат для тестирования на OpenWrt 24.10 с opkg. Браузерные проверки на фиктивных данных, изолированные проверки обработчика и Sentinel, сборка IPK прошли. На реальном роутере комплект пока не проверен. Ядра и модули протоколов устанавливаются отдельно. Sentinel выключен по умолчанию. Это не полный перенос всех экранов Windows-программы.
+
+[url=https://github.com/RichkovGit/OpenWrtStudio/tree/codex/luci-studio/luci]Исходники, установка и ограничения[/url]
+Сборочные IPK доступны в артефактах workflow LuCI Studio после успешной сборки. Перед установкой сделайте резервную копию настроек.
diff --git a/luci/announcements/telegram.txt b/luci/announcements/telegram.txt
new file mode 100644
index 0000000..5a00e97
--- /dev/null
+++ b/luci/announcements/telegram.txt
@@ -0,0 +1,18 @@
+«А перенести OpenWrt Studio прямо в LuCI?» — вызов принят 🙂
+
+Подготовлен OpenWrt Studio LuCI v1.0.0-rc1: тема оформления и приложение, которые работают прямо на роутере, без Windows и .NET.
+
+В комплекте:
+• светлый/тёмный интерфейс и мобильная раскладка;
+• обзор ресурсов, DHCP-клиенты и отчёт для поддержки;
+• импорт WireGuard/AmneziaWG, конструкторы Mihomo и Sing-box, импорт OpenVPN;
+• подключение Tailscale/ZeroTier;
+• диагностика и фоновый Sentinel с восстановлением DNS/прокси;
+• штатные настройки Wi-Fi, сети, DHCP, брандмауэра, cron и пакетов в оформлении Studio.
+
+Это кандидат для тестирования на OpenWrt 24.10 с opkg. Проверки на заглушках прошли; установка и маршрутизация на реальном роутере ещё требуют проверки. Ядра VPN устанавливаются отдельно, Sentinel по умолчанию выключен. Полное совпадение всех возможностей Windows-программы пока не обещаем.
+
+Исходники, инструкция и ограничения:
+https://github.com/RichkovGit/OpenWrtStudio/tree/codex/luci-studio/luci
+
+Сборочные пакеты — в артефактах workflow LuCI Studio после успешного завершения сборки. Перед установкой сохраните резервную копию настроек.
diff --git a/luci/luci-app-studio/LICENSE b/luci/luci-app-studio/LICENSE
new file mode 100644
index 0000000..9d6b1d5
--- /dev/null
+++ b/luci/luci-app-studio/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 OpenWrt Studio Team
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/luci/luci-app-studio/Makefile b/luci/luci-app-studio/Makefile
new file mode 100644
index 0000000..1785748
--- /dev/null
+++ b/luci/luci-app-studio/Makefile
@@ -0,0 +1,11 @@
+include $(TOPDIR)/rules.mk
+PKG_NAME:=luci-app-studio
+PKG_VERSION:=1.0.0~rc1
+PKG_RELEASE:=1
+LUCI_TITLE:=OpenWrt Studio router management
+LUCI_DEPENDS:=+luci-base +rpcd +rpcd-mod-file +rpcd-mod-iwinfo +luci-mod-admin-full +jshn +luci-app-firewall +luci-app-opkg
+PKG_LICENSE:=MIT
+define Package/luci-app-studio/conffiles
+/etc/config/studio
+endef
+include $(TOPDIR)/feeds/luci/luci.mk
diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/capabilities.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/capabilities.js
new file mode 100644
index 0000000..807ec6d
--- /dev/null
+++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/capabilities.js
@@ -0,0 +1,147 @@
+"use strict";
+"require view";
+"require rpc";
+"require ui";
+var status = rpc.declare({ object: "studio", method: "status", expect: {} });
+var action = rpc.declare({
+ object: "studio",
+ method: "action",
+ params: ["name", "service"],
+ expect: {},
+});
+var routes = {
+ "studio-sentinel": "admin/studio/sentinel",
+ mihomo: "admin/studio/mihomo",
+ "sing-box": "admin/services/sing-box",
+ xray: "admin/services/passwall2",
+ tailscale: "admin/services/tailscale",
+ zerotier: "admin/services/zerotier",
+ openvpn: "admin/services/openvpn",
+ smartdns: "admin/services/smartdns",
+ mosdns: "admin/services/mosdns",
+ passwall2: "admin/services/passwall2",
+ dnsmasq: "admin/network/dhcp",
+};
+return view.extend({
+ load: function () {
+ return status();
+ },
+ render: function (data) {
+ function run(service, name, button) {
+ ui.showModal("Подтверждение", [
+ E(
+ "p",
+ {},
+ name +
+ " · " +
+ service +
+ ". Действие может прервать соединение с роутером.",
+ ),
+ E("div", { class: "right" }, [
+ E("button", { class: "cbi-button", click: ui.hideModal }, "Отмена"),
+ E(
+ "button",
+ {
+ class: "cbi-button cbi-button-positive",
+ click: function () {
+ ui.hideModal();
+ button.disabled = true;
+ action(name, service)
+ .then(function (r) {
+ if (!r.ok) throw new Error(r.error || r.output);
+ ui.addNotification(
+ null,
+ E("p", {}, "Команда выполнена: " + service),
+ "info",
+ );
+ })
+ .catch(function (e) {
+ ui.addNotification(null, E("p", {}, e.message), "error");
+ })
+ .finally(function () {
+ button.disabled = false;
+ });
+ },
+ },
+ "Выполнить",
+ ),
+ ]),
+ ]);
+ }
+ return E("div", {}, [
+ E("h2", {}, "Карта возможностей"),
+ E(
+ "p",
+ {},
+ "Состояние служб определяется на роутере. Для настройки протокола используется его LuCI-модуль; доступность зависит от установленных пакетов.",
+ ),
+ E("div", { class: "studio-links" }, [
+ E(
+ "a",
+ { href: L.url("admin/network/network") },
+ "WireGuard / AmneziaWG · интерфейсы",
+ ),
+ E("a", { href: L.url("admin/system/opkg") }, "Установить пакеты"),
+ ]),
+ E(
+ "div",
+ { class: "studio-grid" },
+ (data.services || []).map(function (s) {
+ return E("div", { class: "studio-card" }, [
+ E("h3", {}, s.name),
+ E(
+ "span",
+ { class: "studio-status" + (s.running ? "" : " studio-warn") },
+ s.running
+ ? "Работает"
+ : s.installed
+ ? "Остановлен"
+ : "Не установлен",
+ ),
+ E(
+ "div",
+ { class: "studio-actions" },
+ s.installed
+ ? ["start", "stop", "restart"].map(function (n) {
+ var button = E(
+ "button",
+ {
+ class: "cbi-button",
+ disabled: !L.hasViewPermission(),
+ click: function () {
+ run(s.name, n, button);
+ },
+ },
+ n,
+ );
+ return button;
+ })
+ : [
+ E(
+ "a",
+ {
+ href:
+ L.url("admin/system/opkg") +
+ "?query=" +
+ encodeURIComponent(s.name),
+ },
+ "Каталог пакетов",
+ ),
+ ],
+ ),
+ s.installed
+ ? E(
+ "a",
+ { href: L.url(routes[s.name]) },
+ "Открыть конфигуратор (если установлен)",
+ )
+ : "",
+ ]);
+ }),
+ ),
+ ]);
+ },
+ handleSaveApply: null,
+ handleSave: null,
+ handleReset: null,
+});
diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/diagnostics.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/diagnostics.js
new file mode 100644
index 0000000..d1775c3
--- /dev/null
+++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/diagnostics.js
@@ -0,0 +1,115 @@
+"use strict";
+"require view";
+"require rpc";
+"require ui";
+var probe = rpc.declare({ object: "studio", method: "probe", expect: {} });
+var action = rpc.declare({
+ object: "studio",
+ method: "action",
+ params: ["name", "service"],
+ expect: {},
+});
+return view.extend({
+ render: function () {
+ var output = E(
+ "pre",
+ {},
+ "Нажмите «Проверить». Проверка обращается к openwrt.org и 1.1.1.1 с роутера. Неудачный ping сам по себе не доказывает сбой провайдера.",
+ );
+ var button = E(
+ "button",
+ {
+ class: "cbi-button cbi-button-positive",
+ click: function () {
+ button.disabled = true;
+ output.textContent = "Проверка…";
+ return probe()
+ .then(function (r) {
+ output.textContent = r.output || r.error || "Нет данных";
+ })
+ .catch(function (e) {
+ output.textContent = e.message;
+ })
+ .finally(function () {
+ button.disabled = false;
+ });
+ },
+ },
+ "Проверить сеть",
+ );
+ function recover(service) {
+ ui.showModal("Sentinel · восстановление", [
+ E(
+ "p",
+ {},
+ "Перезапустить " +
+ service +
+ "? Возможен кратковременный обрыв доступа.",
+ ),
+ E("div", { class: "right" }, [
+ E("button", { class: "cbi-button", click: ui.hideModal }, "Отмена"),
+ E(
+ "button",
+ {
+ class: "cbi-button cbi-button-positive",
+ click: function () {
+ ui.hideModal();
+ return action("restart", service)
+ .then(function (r) {
+ output.textContent =
+ (r.ok
+ ? "Команда выполнена. Повторите диагностику.\n"
+ : "Ошибка: ") + (r.error || r.output || "");
+ })
+ .catch(function (e) {
+ output.textContent = e.message;
+ });
+ },
+ },
+ "Перезапустить",
+ ),
+ ]),
+ ]);
+ }
+ return E("div", {}, [
+ E("h2", {}, "Диагностика · Sentinel"),
+ E(
+ "p",
+ {},
+ "Проверка WAN, DNS, маршрутов и интерфейсов. Восстановление запускается вручную; автоматические изменения сети отключены.",
+ ),
+ E(
+ "div",
+ { class: "studio-actions" },
+ [button].concat(
+ ["dnsmasq", "mihomo", "sing-box"].map(function (s) {
+ return E(
+ "button",
+ {
+ class: "cbi-button",
+ disabled: !L.hasViewPermission(),
+ click: function () {
+ recover(s);
+ },
+ },
+ "Перезапустить " + s,
+ );
+ }),
+ ),
+ ),
+ E("div", { class: "studio-card" }, [output]),
+ E("div", { class: "studio-links" }, [
+ E(
+ "a",
+ { href: L.url("admin/network/diagnostics") },
+ "Ping / traceroute / nslookup",
+ ),
+ E("a", { href: L.url("admin/status/nftables") }, "Правила nftables"),
+ E("a", { href: L.url("admin/status/syslog") }, "Системный журнал"),
+ ]),
+ ]);
+ },
+ handleSaveApply: null,
+ handleSave: null,
+ handleReset: null,
+});
diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/mesh.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/mesh.js
new file mode 100644
index 0000000..01dc740
--- /dev/null
+++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/mesh.js
@@ -0,0 +1,116 @@
+"use strict";
+"require view";
+"require rpc";
+"require ui";
+var configure = rpc.declare({
+ object: "studio",
+ method: "mesh",
+ params: ["kind", "key", "routes", "exitnode", "accept"],
+ expect: {},
+});
+return view.extend({
+ render: function () {
+ var auth = E("input", {
+ type: "password",
+ autocomplete: "off",
+ placeholder: "tskey-auth-…",
+ }),
+ routes = E("input", { placeholder: "192.168.1.0/24,10.0.0.0/24" }),
+ exitnode = E("input", { type: "checkbox" }),
+ accept = E("input", { type: "checkbox" }),
+ network = E("input", { placeholder: "0123456789abcdef" }),
+ output = E("pre");
+ accept.checked = true;
+ function apply(kind) {
+ var key = (kind === "tailscale" ? auth : network).value.trim();
+ if (
+ kind === "tailscale"
+ ? !/^tskey-auth-[A-Za-z0-9_-]+$/.test(key)
+ : !/^[0-9a-f]{16}$/i.test(key)
+ ) {
+ output.textContent = "Проверьте ключ / ID сети";
+ return;
+ }
+ ui.showModal("Подключить " + kind + "?", [
+ E(
+ "p",
+ {},
+ "Роутер подключится к указанной VPN-сети. Для Tailscale анонс маршрутов и Exit Node требуют одобрения в панели tailnet. Для ZeroTier подтвердите участника в контроллере сети.",
+ ),
+ E("button", { class: "cbi-button", click: ui.hideModal }, "Отмена"),
+ E(
+ "button",
+ {
+ class: "cbi-button cbi-button-positive",
+ click: function () {
+ ui.hideModal();
+ output.textContent = "Подключение…";
+ return configure(
+ kind,
+ key,
+ routes.value.trim(),
+ exitnode.checked,
+ accept.checked,
+ )
+ .then(function (r) {
+ output.textContent =
+ (r.ok ? "Готово: " : "Ошибка: ") +
+ (r.error || r.output || "");
+ if (r.ok) auth.value = "";
+ })
+ .catch(function (e) {
+ output.textContent = e.message;
+ });
+ },
+ },
+ "Подключить",
+ ),
+ ]);
+ }
+ return E("div", {}, [
+ E("h2", {}, "Mesh VPN"),
+ E("div", { class: "studio-grid" }, [
+ E("div", { class: "studio-card" }, [
+ E("h3", {}, "Tailscale"),
+ E("label", {}, "Auth key"),
+ auth,
+ E("label", {}, "Подсети для анонса"),
+ routes,
+ E("p", {}, [exitnode, " Анонсировать Exit Node"]),
+ E("p", {}, [accept, " Принимать маршруты tailnet"]),
+ E(
+ "button",
+ {
+ class: "cbi-button cbi-button-positive",
+ disabled: !L.hasViewPermission(),
+ click: function () {
+ apply("tailscale");
+ },
+ },
+ "Подключить Tailscale",
+ ),
+ ]),
+ E("div", { class: "studio-card" }, [
+ E("h3", {}, "ZeroTier"),
+ E("label", {}, "Network ID"),
+ network,
+ E(
+ "button",
+ {
+ class: "cbi-button cbi-button-positive",
+ disabled: !L.hasViewPermission(),
+ click: function () {
+ apply("zerotier");
+ },
+ },
+ "Подключить ZeroTier",
+ ),
+ ]),
+ ]),
+ output,
+ ]);
+ },
+ handleSaveApply: null,
+ handleSave: null,
+ handleReset: null,
+});
diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/mihomo.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/mihomo.js
new file mode 100644
index 0000000..73a9ba6
--- /dev/null
+++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/mihomo.js
@@ -0,0 +1,190 @@
+"use strict";
+"require view";
+"require rpc";
+"require ui";
+var deploy = rpc.declare({
+ object: "studio",
+ method: "mihomo",
+ params: ["content"],
+ expect: {},
+});
+function config(url, mode) {
+ if (!/^https:\/\/[^\s]+$/.test(url))
+ throw new Error("Нужна HTTPS-ссылка на подписку в формате Mihomo.");
+ return {
+ "mixed-port": 7890,
+ "allow-lan": false,
+ mode: "rule",
+ "log-level": "info",
+ tun: {
+ enable: true,
+ stack: "mixed",
+ "auto-route": true,
+ "auto-detect-interface": true,
+ "dns-hijack": ["any:53"],
+ },
+ dns: {
+ enable: true,
+ listen: "127.0.0.1:1053",
+ "enhanced-mode": "fake-ip",
+ nameserver: ["https://1.1.1.1/dns-query"],
+ "fake-ip-filter": ["*.lan", "*.local"],
+ },
+ "proxy-providers": {
+ subscription: {
+ type: "http",
+ url: url,
+ interval: 86400,
+ path: "./proxy_providers/studio.yaml",
+ "health-check": {
+ enable: true,
+ url: "https://www.gstatic.com/generate_204",
+ interval: 300,
+ },
+ },
+ },
+ "proxy-groups": [
+ { name: "PROXY", type: "select", use: ["subscription"] },
+ {
+ name: "AUTO",
+ type: "url-test",
+ use: ["subscription"],
+ url: "https://www.gstatic.com/generate_204",
+ interval: 300,
+ },
+ ],
+ rules: [
+ "IP-CIDR,127.0.0.0/8,DIRECT",
+ "IP-CIDR,192.168.0.0/16,DIRECT",
+ "IP-CIDR,10.0.0.0/8,DIRECT",
+ "IP-CIDR,172.16.0.0/12,DIRECT",
+ ].concat(
+ mode === "smart"
+ ? [
+ "GEOIP,RU,DIRECT",
+ "GEOSITE,youtube,PROXY",
+ "GEOSITE,discord,PROXY",
+ "MATCH,PROXY",
+ ]
+ : ["MATCH,PROXY"],
+ ),
+ };
+}
+return view.extend({
+ render: function () {
+ var subscription = E("input", {
+ type: "password",
+ class: "cbi-input-text",
+ placeholder: "https://…",
+ autocomplete: "off",
+ });
+ var preset = E("select", { class: "cbi-input-select" }, [
+ E(
+ "option",
+ { value: "smart" },
+ "RU напрямую, внешний трафик через прокси",
+ ),
+ E("option", { value: "full" }, "Полный туннель"),
+ ]);
+ var editor = E("textarea", {
+ class: "studio-editor",
+ spellcheck: "false",
+ placeholder: "Конфигурация JSON (допустимое подмножество YAML)",
+ });
+ var result = E("pre");
+ return E("div", {}, [
+ E("h2", {}, "Конструктор Mihomo"),
+ E(
+ "p",
+ {},
+ "Подписка должна возвращать YAML с узлами Mihomo. Генератор создаёт конфигурацию с TUN и обновлением подписки раз в сутки. Правила GEOSITE/GEOIP требуют геобаз Mihomo. Парольная ссылка остаётся только в форме и конфигурации роутера.",
+ ),
+ E("div", { class: "studio-card" }, [
+ E("label", {}, "Подписка HTTPS"),
+ subscription,
+ E("label", {}, "Маршрутизация"),
+ preset,
+ E("div", { class: "studio-actions" }, [
+ E(
+ "button",
+ {
+ class: "cbi-button",
+ click: function () {
+ try {
+ editor.value = JSON.stringify(
+ config(subscription.value.trim(), preset.value),
+ null,
+ 2,
+ );
+ } catch (e) {
+ ui.addNotification(null, E("p", {}, e.message), "error");
+ }
+ },
+ },
+ "Сформировать",
+ ),
+ ]),
+ editor,
+ E("div", { class: "studio-actions" }, [
+ E(
+ "button",
+ {
+ class: "cbi-button cbi-button-positive",
+ disabled: !L.hasViewPermission(),
+ click: function () {
+ var content = editor.value;
+ try {
+ var parsed = JSON.parse(content);
+ if (!parsed["proxy-groups"] || !parsed.rules)
+ throw new Error("Нужны proxy-groups и rules");
+ } catch (e) {
+ result.textContent = "Ошибка JSON: " + e.message;
+ return;
+ }
+ ui.showModal("Применить Mihomo?", [
+ E(
+ "p",
+ {},
+ "Будет заменён /etc/mihomo/config.yaml и перезапущена служба mihomo. Предыдущая конфигурация сохранится в config.yaml.studio-backup. TUN меняет маршрутизацию и может прервать доступ. Убедитесь, что служба использует этот путь.",
+ ),
+ E("div", { class: "right" }, [
+ E(
+ "button",
+ { class: "cbi-button", click: ui.hideModal },
+ "Отмена",
+ ),
+ E(
+ "button",
+ {
+ class: "cbi-button cbi-button-positive",
+ click: function () {
+ ui.hideModal();
+ result.textContent = "Проверка и применение…";
+ return deploy(content)
+ .then(function (r) {
+ result.textContent =
+ (r.ok ? "Готово: " : "Ошибка: ") +
+ (r.error || r.output || "");
+ })
+ .catch(function (e) {
+ result.textContent = e.message;
+ });
+ },
+ },
+ "Проверить и применить",
+ ),
+ ]),
+ ]);
+ },
+ },
+ "Проверить ядром Mihomo и применить",
+ ),
+ ]),
+ result,
+ ]),
+ ]);
+ },
+ handleSaveApply: null,
+ handleSave: null,
+ handleReset: null,
+});
diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/overview.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/overview.js
new file mode 100644
index 0000000..9670b86
--- /dev/null
+++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/overview.js
@@ -0,0 +1,193 @@
+"use strict";
+"require view";
+"require rpc";
+"require poll";
+"require ui";
+var board = rpc.declare({ object: "system", method: "board", expect: {} });
+var info = rpc.declare({ object: "system", method: "info", expect: {} });
+var status = rpc.declare({ object: "studio", method: "status", expect: {} });
+var leases = rpc.declare({
+ object: "luci-rpc",
+ method: "getDHCPLeases",
+ expect: {},
+});
+function card(title, value, detail, percent) {
+ return E("div", { class: "studio-card" }, [
+ E("h3", {}, title),
+ E("div", { class: "studio-value" }, String(value)),
+ E("p", { class: "studio-muted" }, detail || ""),
+ percent == null
+ ? ""
+ : E("div", { class: "studio-meter" }, [
+ E("span", {
+ style: "width:" + Math.max(0, Math.min(100, percent)) + "%",
+ }),
+ ]),
+ ]);
+}
+return view.extend({
+ load: function () {
+ return Promise.all([board(), info(), status(), leases()]);
+ },
+ render: function (data) {
+ var root = E("div"),
+ latest = data;
+ function draw(d) {
+ latest = d;
+ var b = d[0],
+ i = d[1],
+ s = d[2],
+ m = i.memory || {},
+ ram = m.total
+ ? (100 *
+ (m.total -
+ (m.available != null
+ ? m.available
+ : (m.free || 0) + (m.buffered || 0) + (m.cached || 0)))) /
+ m.total
+ : null;
+ var clients = d[3].dhcp_leases || [],
+ load = (i.load || [])
+ .map(function (n) {
+ return (n / 65536).toFixed(2);
+ })
+ .join(" / ");
+ root.replaceChildren(
+ E("div", { class: "studio-hero" }, [
+ E("div", {}, [
+ E("h2", {}, "Ваша сеть под контролем"),
+ E(
+ "p",
+ {},
+ (b.model || "OpenWrt") +
+ " · " +
+ ((b.release || {}).description || "") +
+ " · " +
+ new Date().toLocaleTimeString(),
+ ),
+ ]),
+ E("span", { class: "studio-status" }, "Подключено · LuCI RPC"),
+ ]),
+ E("div", { class: "studio-grid" }, [
+ card("Загрузка системы", load, "Load average · 1 / 5 / 15 минут"),
+ card(
+ "Оперативная память",
+ ram == null ? "Нет данных" : ram.toFixed(1) + "%",
+ "С учётом MemAvailable",
+ ram,
+ ),
+ card(
+ "Хранилище overlay",
+ s.overlay || "Нет данных",
+ "Занято места",
+ parseFloat(s.overlay) || 0,
+ ),
+ card(
+ "Время работы",
+ Math.floor((i.uptime || 0) / 3600) +
+ " ч " +
+ Math.floor(((i.uptime || 0) % 3600) / 60) +
+ " мин",
+ s.architecture,
+ ),
+ ]),
+ E("div", { class: "studio-actions" }, [
+ E(
+ "button",
+ { class: "cbi-button", click: report },
+ "Сохранить отчёт",
+ ),
+ E(
+ "a",
+ { class: "cbi-button", href: L.url("admin/studio/diagnostics") },
+ "Проверить сеть",
+ ),
+ ]),
+ E("h3", { id: "studio-clients" }, "Клиенты DHCP"),
+ E(
+ "p",
+ { class: "studio-muted" },
+ "Активные IPv4-аренды. Статические адреса и ограничения доступа настраиваются в разделе «Настройки и инструменты».",
+ ),
+ E(
+ "table",
+ { class: "table" },
+ [
+ E(
+ "tr",
+ { class: "tr table-titles" },
+ ["Имя", "IP-адрес", "MAC-адрес"].map(function (x) {
+ return E("th", { class: "th" }, x);
+ }),
+ ),
+ ].concat(
+ clients.map(function (c) {
+ return E(
+ "tr",
+ { class: "tr" },
+ [c.hostname || "—", c.ipaddr || "—", c.macaddr || "—"].map(
+ function (x) {
+ return E("td", { class: "td" }, x);
+ },
+ ),
+ );
+ }),
+ ),
+ ),
+ );
+ if (ram > 92 || parseFloat(s.overlay) > 92)
+ root.appendChild(
+ E(
+ "div",
+ { class: "alert-message warning" },
+ "Ресурсы роутера близки к исчерпанию. Проверьте память и свободное место.",
+ ),
+ );
+ }
+ function report() {
+ var b = latest[0],
+ i = latest[1],
+ s = latest[2];
+ var text = JSON.stringify(
+ {
+ application: "OpenWrt Studio LuCI 1.0.0-rc1",
+ model: b.model,
+ release: (b.release || {}).description,
+ uptime: i.uptime,
+ load: i.load,
+ memory: i.memory,
+ overlay: s.overlay,
+ architecture: s.architecture,
+ },
+ null,
+ 2,
+ );
+ var url = URL.createObjectURL(
+ new Blob([text], { type: "text/plain;charset=utf-8" }),
+ );
+ var a = E("a", { href: url, download: "openwrt-studio-report.txt" });
+ a.click();
+ setTimeout(function () {
+ URL.revokeObjectURL(url);
+ }, 1000);
+ }
+ draw(data);
+ poll.add(function () {
+ return Promise.all([board(), info(), status(), leases()])
+ .then(draw)
+ .catch(function (e) {
+ root.replaceChildren(
+ E(
+ "div",
+ { class: "alert-message warning" },
+ "Данные недоступны: " + e.message,
+ ),
+ );
+ });
+ }, 25);
+ return root;
+ },
+ handleSaveApply: null,
+ handleSave: null,
+ handleReset: null,
+});
diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/sentinel.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/sentinel.js
new file mode 100644
index 0000000..6157278
--- /dev/null
+++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/sentinel.js
@@ -0,0 +1,52 @@
+"use strict";
+"require view";
+"require form";
+"require uci";
+return view.extend({
+ load: function () {
+ return uci.load("studio");
+ },
+ render: function () {
+ var m = new form.Map(
+ "studio",
+ "Sentinel · фоновый мониторинг",
+ "Проверяет внешнюю связность и локальный DNS непосредственно на роутере. Работает после закрытия браузера. Включается по желанию. Повторные сбои WAN и внешней сети записываются в журнал; восстановление ограничено DNS и выбранной службой прокси.",
+ );
+ var s = m.section(form.NamedSection, "sentinel", "sentinel");
+ s.addremove = false;
+ var o = s.option(form.Flag, "enabled", "Включить Sentinel");
+ o.rmempty = false;
+ o = s.option(form.Value, "interval", "Период проверки, секунд");
+ o.datatype = "range(30,3600)";
+ o.default = "60";
+ o.rmempty = false;
+ o = s.option(form.Value, "failures", "Сбоев подряд до восстановления");
+ o.datatype = "range(2,20)";
+ o.default = "3";
+ o.rmempty = false;
+ o = s.option(
+ form.Value,
+ "cooldown",
+ "Пауза между восстановлениями, секунд",
+ );
+ o.datatype = "range(300,86400)";
+ o.default = "300";
+ o.rmempty = false;
+ o = s.option(
+ form.Flag,
+ "dns",
+ "Перезапускать dnsmasq при локальном сбое DNS",
+ );
+ o.rmempty = false;
+ o = s.option(
+ form.ListValue,
+ "proxy",
+ "Перезапускать остановленное ядро при потере связи",
+ );
+ o.value("none", "Не перезапускать");
+ o.value("mihomo", "Mihomo");
+ o.value("sing-box", "Sing-box");
+ o.default = "none";
+ return m.render();
+ },
+});
diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/tools.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/tools.js
new file mode 100644
index 0000000..0b874a0
--- /dev/null
+++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/tools.js
@@ -0,0 +1,101 @@
+"use strict";
+"require view";
+"require rpc";
+var status = rpc.declare({ object: "studio", method: "status", expect: {} });
+return view.extend({
+ load: function () {
+ return status();
+ },
+ render: function (data) {
+ var groups = [
+ [
+ "Сеть и устройства",
+ [
+ ["Wi-Fi: SSID, пароль, WPA2/WPA3", "admin/network/wireless"],
+ ["WAN / LAN / WireGuard / AmneziaWG", "admin/network/network"],
+ ["DHCP и статические аренды", "admin/network/dhcp"],
+ ["Брандмауэр, ограничения клиентов", "admin/network/firewall"],
+ ["Проброс портов", "admin/network/firewall/forwards"],
+ ["Статические маршруты", "admin/network/routing"],
+ ],
+ ],
+ [
+ "Система",
+ [
+ ["Имя роутера, время и тема", "admin/system/system"],
+ ["Пароль администратора и SSH", "admin/system/admin"],
+ ["Резервная копия и прошивка", "admin/system/flash"],
+ ["Планировщик cron", "admin/system/crontab"],
+ ["Пакеты и источники ForkOP", "admin/system/opkg"],
+ ["Службы и автозапуск", "admin/system/startup"],
+ ["Перезагрузка", "admin/system/reboot"],
+ ["USB и точки монтирования (luci-app-mount)", "admin/system/mounts"],
+ ],
+ ],
+ ];
+ return E("div", {}, [
+ E("h2", {}, "Настройки и инструменты"),
+ E(
+ "p",
+ {},
+ "Штатные формы LuCI работают с UCI, проверкой полей и подтверждением применения. Для WireGuard нужен luci-proto-wireguard, для AmneziaWG — совместимый модуль вашей прошивки. Источники ForkOP добавляются для конкретной версии OpenWrt и архитектуры.",
+ ),
+ E(
+ "div",
+ { class: "studio-grid" },
+ groups.map(function (g) {
+ return E("div", { class: "studio-card" }, [
+ E("h3", {}, g[0]),
+ E(
+ "div",
+ { class: "studio-links" },
+ g[1].map(function (link) {
+ return E("a", { href: L.url(link[1]) }, link[0]);
+ }),
+ ),
+ ]);
+ }),
+ ),
+ E("div", { class: "studio-card" }, [
+ E("h3", {}, "ForkOP · источник для архитектуры"),
+ E(
+ "p",
+ {},
+ "Архитектура: " +
+ (data.architecture || "не определена") +
+ ". Сверьте поддержку вашей версии OpenWrt и ключ подписи в документации источника перед добавлением.",
+ ),
+ E(
+ "pre",
+ {},
+ /^[a-zA-Z0-9_]+$/.test(data.architecture || "")
+ ? "src/gz forkop https://raw.githubusercontent.com/forkop/openwrt-packages/master/" +
+ data.architecture
+ : "Невозможно предложить источник без архитектуры.",
+ ),
+ E(
+ "a",
+ { href: L.url("admin/system/opkg") },
+ "Открыть каталог и настройки источников",
+ ),
+ ]),
+ E("div", { class: "studio-card" }, [
+ E("h3", {}, "Шаблоны cron"),
+ E(
+ "p",
+ {},
+ "Добавляйте задания в системный планировщик после проверки времени роутера. Выберите только нужные строки.",
+ ),
+ E(
+ "pre",
+ {},
+ "0 4 * * * /sbin/reboot\n0 1 * * * /sbin/wifi down\n0 7 * * * /sbin/wifi up\n0 */6 * * * /etc/init.d/mihomo reload",
+ ),
+ E("a", { href: L.url("admin/system/crontab") }, "Открыть планировщик"),
+ ]),
+ ]);
+ },
+ handleSaveApply: null,
+ handleSave: null,
+ handleReset: null,
+});
diff --git a/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/vpn.js b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/vpn.js
new file mode 100644
index 0000000..dad840f
--- /dev/null
+++ b/luci/luci-app-studio/htdocs/luci-static/resources/view/studio/vpn.js
@@ -0,0 +1,468 @@
+"use strict";
+"require view";
+"require uci";
+"require ui";
+"require rpc";
+"require validation";
+function parseWireGuard(text, name, protocol) {
+ if (!/^[a-zA-Z][a-zA-Z0-9_]{0,14}$/.test(name))
+ throw new Error("Имя интерфейса: до 15 латинских букв, цифр и _.");
+ if (protocol !== "wireguard" && protocol !== "amneziawg")
+ throw new Error("Неизвестный протокол");
+ var iface = {},
+ peers = [],
+ section = null;
+ text.split(/\r?\n/).forEach(function (line) {
+ line = line.trim();
+ if (!line || /^[#;]/.test(line)) return;
+ if (line === "[Interface]") {
+ if (section) throw new Error("Повторный Interface");
+ section = iface;
+ return;
+ }
+ if (line === "[Peer]") {
+ section = {};
+ peers.push(section);
+ return;
+ }
+ var m = line.match(/^([A-Za-z0-9]+)\s*=\s*(.*?)\s*$/);
+ if (!m || !section) throw new Error("Неизвестная строка конфигурации");
+ if (Object.prototype.hasOwnProperty.call(section, m[1]))
+ throw new Error("Повторный параметр " + m[1]);
+ section[m[1]] = m[2];
+ });
+ function key(s) {
+ return /^[A-Za-z0-9+/]{43}=$/.test(s || "");
+ }
+ function list(value, cidr) {
+ return (value || "")
+ .split(",")
+ .map(function (x) {
+ return x.trim();
+ })
+ .filter(Boolean)
+ .map(function (x) {
+ var a = x.split("/"),
+ v4 = validation.parseIPv4(a[0]),
+ v6 = validation.parseIPv6(a[0]);
+ if (
+ (!v4 && !v6) ||
+ (cidr &&
+ (a.length !== 2 ||
+ !/^\d+$/.test(a[1]) ||
+ +a[1] > (v4 ? 32 : 128))) ||
+ (!cidr && a.length !== 1)
+ )
+ throw new Error("Неверный адрес: " + x);
+ return x;
+ });
+ }
+ if (!key(iface.PrivateKey) || !peers.length)
+ throw new Error("Нужны PrivateKey и хотя бы один Peer");
+ var values = {
+ proto: protocol,
+ private_key: iface.PrivateKey,
+ addresses: list(iface.Address, true),
+ };
+ if (!values.addresses.length) throw new Error("Не задан Address");
+ if (iface.DNS) values.dns = list(iface.DNS, false);
+ Object.keys(iface).forEach(function (k) {
+ if (
+ [
+ "PrivateKey",
+ "Address",
+ "DNS",
+ "ListenPort",
+ "MTU",
+ "Jc",
+ "Jmin",
+ "Jmax",
+ "S1",
+ "S2",
+ "H1",
+ "H2",
+ "H3",
+ "H4",
+ ].indexOf(k) < 0
+ )
+ throw new Error("Не поддерживается параметр Interface: " + k);
+ });
+ [
+ ["ListenPort", "listen_port", 1, 65535],
+ ["MTU", "mtu", 576, 9000],
+ ["Jc", "jc", 0, 65535],
+ ["Jmin", "jmin", 0, 65535],
+ ["Jmax", "jmax", 0, 65535],
+ ["S1", "s1", 0, 65535],
+ ["S2", "s2", 0, 65535],
+ ["H1", "h1", 0, 4294967295],
+ ["H2", "h2", 0, 4294967295],
+ ["H3", "h3", 0, 4294967295],
+ ["H4", "h4", 0, 4294967295],
+ ].forEach(function (f) {
+ if (iface[f[0]] != null) {
+ var n = iface[f[0]];
+ if (!/^\d+$/.test(n) || +n < f[2] || +n > f[3])
+ throw new Error("Неверный " + f[0]);
+ if (protocol === "wireguard" && /^[JSH]/.test(f[0]))
+ throw new Error("Для параметров обфускации выберите AmneziaWG");
+ values[f[1]] = n;
+ }
+ });
+ var peerValues = peers.map(function (p) {
+ if (!key(p.PublicKey) || (p.PresharedKey && !key(p.PresharedKey)))
+ throw new Error("Неверный ключ Peer");
+ Object.keys(p).forEach(function (k) {
+ if (
+ [
+ "PublicKey",
+ "PresharedKey",
+ "AllowedIPs",
+ "Endpoint",
+ "PersistentKeepalive",
+ ].indexOf(k) < 0
+ )
+ throw new Error("Не поддерживается параметр Peer: " + k);
+ });
+ var v = {
+ public_key: p.PublicKey,
+ allowed_ips: list(p.AllowedIPs, true),
+ route_allowed_ips: "1",
+ };
+ if (!v.allowed_ips.length) throw new Error("Не задан AllowedIPs");
+ if (p.PresharedKey) v.preshared_key = p.PresharedKey;
+ if (p.Endpoint) {
+ var m = p.Endpoint.match(
+ /^(?:\[([0-9a-fA-F:]+)\]|([A-Za-z0-9.-]+)):(\d+)$/,
+ );
+ if (
+ !m ||
+ +m[3] < 1 ||
+ +m[3] > 65535 ||
+ (m[1] && !validation.parseIPv6(m[1]))
+ )
+ throw new Error("Неверный Endpoint");
+ v.endpoint_host = m[1] || m[2];
+ v.endpoint_port = m[3];
+ }
+ if (p.PersistentKeepalive != null) {
+ if (
+ !/^\d+$/.test(p.PersistentKeepalive) ||
+ +p.PersistentKeepalive > 65535
+ )
+ throw new Error("Неверный PersistentKeepalive");
+ v.persistent_keepalive = p.PersistentKeepalive;
+ }
+ return v;
+ });
+ return { name: name, values: values, peers: peerValues, protocol: protocol };
+}
+var saveConfig = rpc.declare({
+ object: "studio",
+ method: "vpnconfig",
+ params: ["kind", "content"],
+ expect: {},
+});
+function singbox(fields) {
+ var port = +fields.port;
+ if (
+ !/^[A-Za-z0-9.:-]+$/.test(fields.server) ||
+ port < 1 ||
+ port > 65535 ||
+ !Number.isInteger(port)
+ )
+ throw new Error("Проверьте сервер и порт");
+ var outbound = {
+ type: fields.protocol,
+ tag: "proxy",
+ server: fields.server,
+ server_port: port,
+ tls: { enabled: true, server_name: fields.sni },
+ };
+ if (fields.protocol === "vless") {
+ if (
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
+ fields.uuid,
+ )
+ )
+ throw new Error("Нужен UUID");
+ outbound.uuid = fields.uuid;
+ outbound.flow = "xtls-rprx-vision";
+ outbound.tls.utls = { enabled: true, fingerprint: "chrome" };
+ outbound.tls.reality = {
+ enabled: true,
+ public_key: fields.publickey,
+ short_id: fields.shortid,
+ };
+ if (
+ !/^[A-Za-z0-9_-]{43}$/.test(fields.publickey) ||
+ !/^([0-9a-f]{2}){0,8}$/i.test(fields.shortid)
+ )
+ throw new Error("Проверьте Reality public key / short ID");
+ } else if (fields.protocol === "hysteria2") {
+ if (!fields.password) throw new Error("Нужен пароль");
+ outbound.password = fields.password;
+ } else if (fields.protocol === "tuic") {
+ if (
+ !fields.password ||
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
+ fields.uuid,
+ )
+ )
+ throw new Error("Нужны UUID и пароль");
+ outbound.uuid = fields.uuid;
+ outbound.password = fields.password;
+ outbound.congestion_control = "bbr";
+ } else throw new Error("Неизвестный протокол");
+ return {
+ log: { level: "info" },
+ inbounds: [
+ {
+ type: "tun",
+ tag: "tun-in",
+ interface_name: "studio-tun",
+ address: ["172.19.0.1/30"],
+ auto_route: true,
+ strict_route: true,
+ },
+ ],
+ outbounds: [outbound, { type: "direct", tag: "direct" }],
+ route: {
+ auto_detect_interface: true,
+ rules: [{ ip_is_private: true, outbound: "direct" }],
+ final: "proxy",
+ },
+ };
+}
+return view.extend({
+ parseWireGuard: parseWireGuard,
+ buildSingbox: singbox,
+ load: function () {
+ return uci.load("network");
+ },
+ render: function () {
+ var name = E("input", { value: "awg0" }),
+ protocol = E("select", {}, [
+ E("option", { value: "amneziawg" }, "AmneziaWG"),
+ E("option", { value: "wireguard" }, "WireGuard"),
+ ]),
+ text = E("textarea", {
+ class: "studio-editor",
+ placeholder:
+ "[Interface]\nPrivateKey = …\nAddress = …\n[Peer]\nPublicKey = …\nAllowedIPs = …",
+ }),
+ output = E("pre");
+ var file = E("input", { type: "file", accept: ".conf" });
+ file.addEventListener("change", function () {
+ if (file.files[0])
+ file.files[0].text().then(function (t) {
+ text.value = t;
+ });
+ });
+ function stage() {
+ var c;
+ try {
+ c = parseWireGuard(text.value, name.value, protocol.value);
+ if (uci.get("network", c.name))
+ throw new Error("Интерфейс уже существует. Выберите другое имя.");
+ } catch (e) {
+ output.textContent = e.message;
+ return;
+ }
+ ui.showModal("Добавить интерфейс?", [
+ E(
+ "p",
+ {},
+ "Будет добавлен " +
+ c.name +
+ " и " +
+ c.peers.length +
+ " peer. Импорт создаёт отложенные изменения UCI. Проверьте протокол, зону брандмауэра и маршруты в форме интерфейсов, затем примените изменения с откатом LuCI.",
+ ),
+ E("button", { class: "cbi-button", click: ui.hideModal }, "Отмена"),
+ E(
+ "button",
+ {
+ class: "cbi-button cbi-button-positive",
+ click: function () {
+ ui.hideModal();
+ uci.add("network", "interface", c.name);
+ Object.keys(c.values).forEach(function (k) {
+ uci.set("network", c.name, k, c.values[k]);
+ });
+ c.peers.forEach(function (p) {
+ var sid = uci.add("network", c.protocol + "_" + c.name);
+ Object.keys(p).forEach(function (k) {
+ uci.set("network", sid, k, p[k]);
+ });
+ });
+ return uci
+ .save()
+ .then(function () {
+ location.href = L.url("admin/network/network");
+ })
+ .catch(function (e) {
+ output.textContent = e.message;
+ });
+ },
+ },
+ "Добавить отложенные изменения",
+ ),
+ ]);
+ }
+ var inputs = {};
+ function field(key, label, type, value) {
+ var input = E("input", {
+ type: type || "text",
+ value: value || "",
+ autocomplete: "off",
+ });
+ inputs[key] = input;
+ return E("div", { class: "cbi-value" }, [
+ E("label", { class: "cbi-value-title" }, label),
+ E("div", { class: "cbi-value-field" }, [input]),
+ ]);
+ }
+ var proxyProtocol = E(
+ "select",
+ {},
+ ["vless", "hysteria2", "tuic"].map(function (x) {
+ return E("option", { value: x }, x);
+ }),
+ );
+ var editor = E("textarea", { class: "studio-editor", spellcheck: "false" }),
+ result = E("pre");
+ function apply(kind, content) {
+ ui.showModal("Применить " + kind + "?", [
+ E(
+ "p",
+ {},
+ "Sing-box проверяется ядром; профиль OpenVPN проверяется по списку допустимых клиентских директив. Файл сохраняется с резервной копией, затем служба перезапускается. Изменение маршрутов может прервать доступ к роутеру. Для OpenVPN допускается только клиентский профиль с встроенными сертификатами без сценариев и внешних файлов.",
+ ),
+ E("button", { class: "cbi-button", click: ui.hideModal }, "Отмена"),
+ E(
+ "button",
+ {
+ class: "cbi-button cbi-button-positive",
+ click: function () {
+ ui.hideModal();
+ return saveConfig(kind, content)
+ .then(function (r) {
+ result.textContent =
+ (r.ok ? "Готово: " : "Ошибка: ") +
+ (r.error || r.output || "");
+ })
+ .catch(function (e) {
+ result.textContent = e.message;
+ });
+ },
+ },
+ "Проверить и применить",
+ ),
+ ]);
+ }
+ var ovpn = E("textarea", {
+ class: "studio-editor",
+ placeholder: "client\nremote …\n…\n…",
+ }),
+ ovpnFile = E("input", { type: "file", accept: ".ovpn" });
+ ovpnFile.addEventListener("change", function () {
+ if (ovpnFile.files[0])
+ ovpnFile.files[0].text().then(function (t) {
+ ovpn.value = t;
+ });
+ });
+ return E("div", {}, [
+ E("h2", {}, "Импорт и конструкторы VPN"),
+ E("div", { class: "studio-card" }, [
+ E("h3", {}, "WireGuard / AmneziaWG · импорт .conf"),
+ E(
+ "p",
+ {},
+ "Нужен установленный модуль протокола. Поддерживаются IPv4/IPv6 endpoint, несколько peer, DNS и параметры Jc/Jmin/Jmax/S1/S2/H1–H4. Неподдерживаемые параметры отклоняются.",
+ ),
+ name,
+ protocol,
+ file,
+ text,
+ E(
+ "button",
+ {
+ class: "cbi-button cbi-button-positive",
+ disabled: !L.hasViewPermission(),
+ click: stage,
+ },
+ "Импортировать в UCI",
+ ),
+ output,
+ ]),
+ E("div", { class: "studio-card" }, [
+ E("h3", {}, "Sing-box · VLESS Reality / Hysteria 2 / TUIC"),
+ proxyProtocol,
+ field("server", "Сервер"),
+ field("port", "Порт", "number", "443"),
+ field("sni", "TLS Server Name"),
+ field("uuid", "UUID"),
+ field("password", "Пароль", "password"),
+ field("publickey", "Reality public key"),
+ field("shortid", "Reality short ID"),
+ E(
+ "button",
+ {
+ class: "cbi-button",
+ click: function () {
+ try {
+ var values = { protocol: proxyProtocol.value };
+ Object.keys(inputs).forEach(function (k) {
+ values[k] =
+ k === "password" ? inputs[k].value : inputs[k].value.trim();
+ });
+ editor.value = JSON.stringify(singbox(values), null, 2);
+ } catch (e) {
+ result.textContent = e.message;
+ }
+ },
+ },
+ "Сформировать Sing-box",
+ ),
+ editor,
+ E(
+ "button",
+ {
+ class: "cbi-button cbi-button-positive",
+ disabled: !L.hasViewPermission(),
+ click: function () {
+ try {
+ JSON.parse(editor.value);
+ apply("sing-box", editor.value);
+ } catch (e) {
+ result.textContent = e.message;
+ }
+ },
+ },
+ "Проверить и применить Sing-box",
+ ),
+ ]),
+ E("div", { class: "studio-card" }, [
+ E("h3", {}, "OpenVPN · импорт .ovpn"),
+ ovpnFile,
+ ovpn,
+ E(
+ "button",
+ {
+ class: "cbi-button cbi-button-positive",
+ disabled: !L.hasViewPermission(),
+ click: function () {
+ apply("openvpn", ovpn.value);
+ },
+ },
+ "Импортировать и запустить OpenVPN",
+ ),
+ result,
+ ]),
+ ]);
+ },
+ handleSaveApply: null,
+ handleSave: null,
+ handleReset: null,
+});
diff --git a/luci/luci-app-studio/root/etc/config/studio b/luci/luci-app-studio/root/etc/config/studio
new file mode 100644
index 0000000..81ea1df
--- /dev/null
+++ b/luci/luci-app-studio/root/etc/config/studio
@@ -0,0 +1,7 @@
+config sentinel 'sentinel'
+ option enabled '0'
+ option interval '60'
+ option failures '3'
+ option cooldown '300'
+ option dns '1'
+ option proxy 'none'
diff --git a/luci/luci-app-studio/root/etc/init.d/studio-sentinel b/luci/luci-app-studio/root/etc/init.d/studio-sentinel
new file mode 100755
index 0000000..a4b656f
--- /dev/null
+++ b/luci/luci-app-studio/root/etc/init.d/studio-sentinel
@@ -0,0 +1,17 @@
+#!/bin/sh /etc/rc.common
+START=99
+STOP=10
+USE_PROCD=1
+start_service() {
+ . /lib/functions.sh
+ config_load studio
+ config_get_bool enabled sentinel enabled 0
+ [ "$enabled" = 1 ] || return 0
+ procd_open_instance
+ procd_set_param command /usr/libexec/studio-sentinel
+ procd_set_param respawn 3600 5 5
+ procd_set_param stdout 1
+ procd_set_param stderr 1
+ procd_close_instance
+}
+service_triggers() { procd_add_reload_trigger studio; }
diff --git a/luci/luci-app-studio/root/etc/uci-defaults/91-luci-app-studio b/luci/luci-app-studio/root/etc/uci-defaults/91-luci-app-studio
new file mode 100755
index 0000000..ad02030
--- /dev/null
+++ b/luci/luci-app-studio/root/etc/uci-defaults/91-luci-app-studio
@@ -0,0 +1,4 @@
+#!/bin/sh
+/etc/init.d/studio-sentinel enable
+/etc/init.d/studio-sentinel start
+exit 0
diff --git a/luci/luci-app-studio/root/usr/libexec/rpcd/studio b/luci/luci-app-studio/root/usr/libexec/rpcd/studio
new file mode 100755
index 0000000..17f4be9
--- /dev/null
+++ b/luci/luci-app-studio/root/usr/libexec/rpcd/studio
@@ -0,0 +1,162 @@
+#!/bin/sh
+# Fixed RPC methods; no client-supplied shell command or destination path.
+. /usr/share/libubox/jshn.sh
+emit_error() { json_init; json_add_boolean ok 0; json_add_string error "$1"; json_dump; }
+result() { json_init; json_add_boolean ok "$1"; json_add_string output "$2"; json_dump; }
+case "$1" in
+list)
+ echo '{"status":{},"probe":{},"action":{"name":"","service":""},"mihomo":{"content":""},"vpnconfig":{"kind":"","content":""},"mesh":{"kind":"","key":"","routes":"","exitnode":false,"accept":true}}'
+ ;;
+call)
+ read -r request
+ case "$2" in
+ status)
+ json_init
+ json_add_array services
+ for service in studio-sentinel mihomo sing-box xray dnsmasq tailscale zerotier openvpn smartdns mosdns passwall2; do
+ json_add_object ''
+ json_add_string name "$service"
+ installed=0; [ -x "/etc/init.d/$service" ] && installed=1
+ json_add_boolean installed "$installed"
+ running=0
+ if [ "$installed" = 1 ]; then /etc/init.d/"$service" running >/dev/null 2>&1 && running=1; fi
+ json_add_boolean running "$running"
+ json_close_object
+ done
+ json_close_array
+ json_add_string overlay "$(df -Pk /overlay 2>/dev/null | tail -n 1 | awk '{print $5}')"
+ json_add_string architecture "$(. /etc/openwrt_release; printf '%s' "$DISTRIB_ARCH")"
+ json_add_boolean ok 1
+ json_dump
+ ;;
+ probe)
+ output="$(
+ printf 'WAN\n'; ubus call network.interface.wan status 2>&1
+ printf '\nВнешняя связность (ICMP)\n'
+ ping -c 1 -W 2 1.1.1.1 2>&1
+ printf '\nЛокальный DNS\n'; nslookup openwrt.org 127.0.0.1 2>&1
+ printf '\nПубличный DNS\n'; nslookup openwrt.org 1.1.1.1 2>&1
+ printf '\nИнтерфейсы\n'; ip -s link 2>&1
+ printf '\nМаршруты\n'; ip route 2>&1
+ )"
+ result 1 "$output"
+ ;;
+ action)
+ json_load "$request" || { emit_error 'Invalid JSON'; exit 0; }
+ json_get_var name name
+ json_get_var service service
+ case "$service" in studio-sentinel|mihomo|sing-box|xray|dnsmasq|tailscale|zerotier|openvpn|smartdns|mosdns|passwall2) ;; *) emit_error 'Unsupported service'; exit 0;; esac
+ case "$name" in start|stop|restart|reload) ;; *) emit_error 'Unsupported action'; exit 0;; esac
+ [ -x "/etc/init.d/$service" ] || { emit_error 'Service is not installed'; exit 0; }
+ output="$(/etc/init.d/"$service" "$name" 2>&1)"; code=$?
+ if [ "$code" = 0 ]; then result 1 "$output"; else result 0 "$output"; fi
+ ;;
+ mihomo)
+ json_load "$request" || { emit_error 'Invalid JSON'; exit 0; }
+ json_get_var content content
+ [ "${#content}" -le 262144 ] && [ -n "$content" ] || { emit_error 'Config must be 1–262144 bytes'; exit 0; }
+ command -v mihomo >/dev/null 2>&1 || { emit_error 'Mihomo is not installed'; exit 0; }
+ [ -x /etc/init.d/mihomo ] || { emit_error 'Expected /etc/init.d/mihomo'; exit 0; }
+ [ ! -L /etc/mihomo ] && [ ! -L /etc/mihomo/config.yaml ] && [ ! -L /etc/mihomo/config.yaml.studio-backup ] || { emit_error 'Symlink destination is not supported'; exit 0; }
+ umask 077
+ mkdir -p /etc/mihomo || { emit_error 'Cannot create directory'; exit 0; }
+ mkdir /etc/mihomo/.studio-lock 2>/dev/null || { emit_error 'Another configuration operation is active'; exit 0; }
+ temporary=''
+ trap 'rm -f "$temporary"; rmdir /etc/mihomo/.studio-lock' EXIT
+ trap 'exit 1' HUP INT TERM
+ temporary="$(mktemp /etc/mihomo/.studio.XXXXXX)" || { emit_error 'Cannot create temporary file'; exit 0; }
+ printf '%s\n' "$content" > "$temporary" || { emit_error 'Cannot write config'; exit 0; }
+ output="$(mihomo -t -d /etc/mihomo -f "$temporary" 2>&1)"; code=$?
+ [ "$code" = 0 ] || { result 0 "$output"; exit 0; }
+ if [ -f /etc/mihomo/config.yaml ]; then
+ cp -p /etc/mihomo/config.yaml /etc/mihomo/config.yaml.studio-backup || { emit_error 'Backup failed'; exit 0; }
+ fi
+ mv "$temporary" /etc/mihomo/config.yaml || { emit_error 'Replace failed'; exit 0; }
+ output="$(/etc/init.d/mihomo restart 2>&1)"; code=$?
+ if [ "$code" = 0 ]; then result 1 'Configuration saved; restart command succeeded. Verify service status.'; else result 0 "Configuration saved, restart failed: $output"; fi
+ ;;
+ vpnconfig)
+ json_load "$request" || { emit_error 'Invalid JSON'; exit 0; }
+ json_get_var kind kind
+ json_get_var content content
+ [ -n "$content" ] && [ "${#content}" -le 262144 ] || { emit_error 'Invalid config size'; exit 0; }
+ case "$kind" in
+ sing-box) directory=/etc/sing-box; destination=/etc/sing-box/config.json;;
+ openvpn) directory=/etc/openvpn; destination=/etc/openvpn/studio.conf;;
+ *) emit_error 'Unsupported config type'; exit 0;;
+ esac
+ [ -x "/etc/init.d/$kind" ] && command -v "$kind" >/dev/null 2>&1 || { emit_error 'VPN service/core is not installed'; exit 0; }
+ [ ! -L "$directory" ] && [ ! -L "$destination" ] && [ ! -L "$destination.studio-backup" ] || { emit_error 'Symlink destination is not supported'; exit 0; }
+ umask 077
+ mkdir -p "$directory" || { emit_error 'Cannot create directory'; exit 0; }
+ mkdir "$directory/.studio-lock" 2>/dev/null || { emit_error 'Another configuration operation is active'; exit 0; }
+ temporary=''
+ trap 'rm -f "$temporary"; rmdir "$directory/.studio-lock"' EXIT
+ trap 'exit 1' HUP INT TERM
+ temporary="$(mktemp "$directory/.studio.XXXXXX")" || { emit_error 'Cannot create temporary file'; exit 0; }
+ printf '%s\n' "$content" > "$temporary" || { emit_error 'Cannot write config'; exit 0; }
+ if [ "$kind" = sing-box ]; then
+ output="$(sing-box check -c "$temporary" 2>&1)"; code=$?
+ [ "$code" = 0 ] || { result 0 "$output"; exit 0; }
+ else
+ # Restrict imported OpenVPN to client directives and embedded key material.
+ # Never allow config/include, plugin, scripts, log paths, or external key files.
+ awk '
+ BEGIN { inline=0; client=0; remote=0; failed=0 }
+ /^[[:space:]]*([#;]|$)/ { next }
+ /^<(ca|cert|key|tls-auth|tls-crypt|extra-certs)>[[:space:]]*$/ { if (inline) { failed=1; exit }; inline=1; tag=$0; gsub(/[<>[:space:]]/, "", tag); next }
+ /^<\// { closing=$0; gsub(/[<>\/[:space:]]/, "", closing); if (!inline || closing!=tag) { failed=1; exit }; inline=0; next }
+ inline { next }
+ { key=$1; if (key=="client") client=1; if (key=="remote") remote=1;
+ if (key !~ /^(client|dev|dev-type|proto|remote|remote-random|resolv-retry|nobind|persist-key|persist-tun|remote-cert-tls|verify-x509-name|cipher|data-ciphers|data-ciphers-fallback|auth|auth-nocache|verb|mute|mute-replay-warnings|key-direction|tls-version-min|tls-cipher|tls-ciphersuites|reneg-sec|sndbuf|rcvbuf|tun-mtu|mssfix|explicit-exit-notify|ping|ping-restart|redirect-gateway|route|route-ipv6|route-nopull|pull|pull-filter|dhcp-option|connect-retry|connect-timeout|fast-io|float)$/) { failed=1; exit }
+ if (key=="dev" && $2 !~ /^(tun|tap)[0-9]*$/) { failed=1; exit }
+ }
+ END { if (failed || inline || !client || !remote) exit 1 }
+ ' "$temporary" || { emit_error 'Unsupported or malformed OpenVPN client profile'; exit 0; }
+ fi
+ if [ -f "$destination" ]; then cp -p "$destination" "$destination.studio-backup" || { emit_error 'Backup failed'; exit 0; }; fi
+ mv "$temporary" "$destination" || { emit_error 'Replace failed'; exit 0; }
+ if [ "$kind" = openvpn ]; then
+ uci set openvpn.studio=openvpn && uci set openvpn.studio.config="$destination" && uci set openvpn.studio.enabled=1 && uci commit openvpn || { emit_error 'Config saved; UCI update failed'; exit 0; }
+ fi
+ output="$(/etc/init.d/"$kind" restart 2>&1)"; code=$?
+ if [ "$code" = 0 ]; then result 1 'Configuration saved; restart command succeeded. Verify service status.'; else result 0 "Configuration saved; restart failed: $output"; fi
+ ;;
+ mesh)
+ json_load "$request" || { emit_error 'Invalid JSON'; exit 0; }
+ json_get_var kind kind
+ json_get_var key key
+ json_get_var routes routes
+ json_get_var exitnode exitnode
+ json_get_var accept accept
+ case "$kind" in
+ tailscale)
+ command -v tailscale >/dev/null 2>&1 || { emit_error 'Tailscale is not installed'; exit 0; }
+ case "$key" in tskey-auth-*) ;; *) emit_error 'Invalid Tailscale auth key'; exit 0;; esac
+ case "$key" in *[!A-Za-z0-9_-]*) emit_error 'Invalid auth key characters'; exit 0;; esac
+ case "$routes" in *[!0-9a-fA-F:.,/]*) emit_error 'Invalid subnet characters'; exit 0;; esac
+ case "$exitnode" in 1|true) exitnode=true;; 0|false) exitnode=false;; *) emit_error 'Invalid exitnode flag'; exit 0;; esac
+ case "$accept" in 1|true) accept=true;; 0|false) accept=false;; *) emit_error 'Invalid accept flag'; exit 0;; esac
+ umask 077
+ keyfile="$(mktemp /tmp/studio-tskey.XXXXXX)" || { emit_error 'Cannot create temporary file'; exit 0; }
+ trap 'rm -f "$keyfile"' EXIT
+ trap 'exit 1' HUP INT TERM
+ printf '%s' "$key" > "$keyfile"
+ /etc/init.d/tailscale start >/dev/null 2>&1 || { emit_error 'Cannot start Tailscale service'; exit 0; }
+ output="$(tailscale up --auth-key="file:$keyfile" --accept-routes="$accept" --advertise-exit-node="$exitnode" --advertise-routes="$routes" --timeout=15s 2>&1)"; code=$?
+ ;;
+ zerotier)
+ command -v zerotier-cli >/dev/null 2>&1 || { emit_error 'ZeroTier is not installed'; exit 0; }
+ [ "${#key}" = 16 ] || { emit_error 'ZeroTier network ID must contain 16 hex characters'; exit 0; }
+ case "$key" in *[!0-9a-fA-F]*) emit_error 'Invalid network ID'; exit 0;; esac
+ /etc/init.d/zerotier start >/dev/null 2>&1 || { emit_error 'Cannot start ZeroTier service'; exit 0; }
+ output="$(zerotier-cli join "$key" 2>&1)"; code=$?
+ ;;
+ *) emit_error 'Unsupported mesh VPN'; exit 0;;
+ esac
+ if [ "$code" = 0 ]; then result 1 "$output"; else result 0 "$output"; fi
+ ;;
+ *) emit_error 'Unknown method';;
+ esac
+ ;;
+esac
diff --git a/luci/luci-app-studio/root/usr/libexec/studio-sentinel b/luci/luci-app-studio/root/usr/libexec/studio-sentinel
new file mode 100755
index 0000000..a4b4e99
--- /dev/null
+++ b/luci/luci-app-studio/root/usr/libexec/studio-sentinel
@@ -0,0 +1,38 @@
+#!/bin/sh
+# Opt-in recovery. Never restarts the entire network or changes routes/DNS settings.
+. /lib/functions.sh
+config_load studio
+config_get interval sentinel interval 60
+config_get failures sentinel failures 3
+config_get cooldown sentinel cooldown 300
+config_get_bool dns sentinel dns 1
+config_get proxy sentinel proxy none
+case "$interval" in ''|*[!0-9]*) interval=60;; esac
+case "$failures" in ''|*[!0-9]*) failures=3;; esac
+case "$cooldown" in ''|*[!0-9]*) cooldown=300;; esac
+[ "$interval" -ge 30 ] && [ "$interval" -le 3600 ] || interval=60
+[ "$failures" -ge 2 ] && [ "$failures" -le 20 ] || failures=3
+[ "$cooldown" -ge 300 ] && [ "$cooldown" -le 86400 ] || cooldown=300
+case "$proxy" in mihomo|sing-box|none) ;; *) proxy=none;; esac
+count=0; previous=''; last=0
+while :; do
+ issue=healthy; recovery=''
+ if ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1 || ping -c 1 -W 2 77.88.8.8 >/dev/null 2>&1; then
+ if ! nslookup openwrt.org 127.0.0.1 >/dev/null 2>&1; then issue=local_dns; [ "$dns" = 1 ] && recovery=dnsmasq; fi
+ else
+ wan="$(ubus call network.interface.wan status 2>/dev/null | jsonfilter -e '@.up')"
+ if [ "$wan" != true ]; then issue=wan_down; else issue=upstream_or_tunnel; fi
+ if [ "$proxy" != none ] && [ -x "/etc/init.d/$proxy" ] && ! /etc/init.d/"$proxy" running >/dev/null 2>&1; then issue=proxy_stopped; recovery="$proxy"; fi
+ fi
+ if [ "$issue" = "$previous" ]; then count=$((count + 1)); else
+ logger -t studio-sentinel "Health state: $issue"
+ count=1; previous="$issue"
+ fi
+ now="$(date +%s)"
+ if [ -n "$recovery" ] && [ "$count" -ge "$failures" ] && [ $((now - last)) -ge "$cooldown" ]; then
+ logger -t studio-sentinel "Recovery: restarting $recovery after $count failed checks"
+ if /etc/init.d/"$recovery" restart; then logger -t studio-sentinel 'Restart command succeeded'; else logger -t studio-sentinel 'Restart command failed'; fi
+ last="$now"; count=0
+ fi
+ sleep "$interval"
+done
diff --git a/luci/luci-app-studio/root/usr/share/luci/menu.d/luci-app-studio.json b/luci/luci-app-studio/root/usr/share/luci/menu.d/luci-app-studio.json
new file mode 100644
index 0000000..879e0c5
--- /dev/null
+++ b/luci/luci-app-studio/root/usr/share/luci/menu.d/luci-app-studio.json
@@ -0,0 +1,78 @@
+{
+ "admin/studio": {
+ "title": "OpenWrt Studio",
+ "order": 15,
+ "action": {
+ "type": "firstchild"
+ },
+ "depends": {
+ "acl": [
+ "luci-app-studio"
+ ]
+ }
+ },
+ "admin/studio/overview": {
+ "title": "Обзор сети",
+ "order": 1,
+ "action": {
+ "type": "view",
+ "path": "studio/overview"
+ }
+ },
+ "admin/studio/capabilities": {
+ "title": "VPN и возможности",
+ "order": 2,
+ "action": {
+ "type": "view",
+ "path": "studio/capabilities"
+ }
+ },
+ "admin/studio/diagnostics": {
+ "title": "Диагностика · Sentinel",
+ "order": 3,
+ "action": {
+ "type": "view",
+ "path": "studio/diagnostics"
+ }
+ },
+ "admin/studio/mihomo": {
+ "title": "Конструктор Mihomo",
+ "order": 4,
+ "action": {
+ "type": "view",
+ "path": "studio/mihomo"
+ }
+ },
+ "admin/studio/tools": {
+ "title": "Настройки и инструменты",
+ "order": 5,
+ "action": {
+ "type": "view",
+ "path": "studio/tools"
+ }
+ },
+ "admin/studio/vpn": {
+ "title": "Конструкторы VPN",
+ "order": 3,
+ "action": {
+ "type": "view",
+ "path": "studio/vpn"
+ }
+ },
+ "admin/studio/mesh": {
+ "title": "Tailscale / ZeroTier",
+ "order": 4,
+ "action": {
+ "type": "view",
+ "path": "studio/mesh"
+ }
+ },
+ "admin/studio/sentinel": {
+ "title": "Фоновый Sentinel",
+ "order": 6,
+ "action": {
+ "type": "view",
+ "path": "studio/sentinel"
+ }
+ }
+}
diff --git a/luci/luci-app-studio/root/usr/share/rpcd/acl.d/luci-app-studio.json b/luci/luci-app-studio/root/usr/share/rpcd/acl.d/luci-app-studio.json
new file mode 100644
index 0000000..b67aef5
--- /dev/null
+++ b/luci/luci-app-studio/root/usr/share/rpcd/acl.d/luci-app-studio.json
@@ -0,0 +1,42 @@
+{
+ "luci-app-studio": {
+ "description": "OpenWrt Studio: monitor and manage supported services",
+ "read": {
+ "ubus": {
+ "system": [
+ "board",
+ "info"
+ ],
+ "network.interface": [
+ "dump"
+ ],
+ "luci-rpc": [
+ "getDHCPLeases",
+ "getHostHints"
+ ],
+ "studio": [
+ "status",
+ "probe"
+ ]
+ },
+ "uci": [
+ "network",
+ "studio"
+ ]
+ },
+ "write": {
+ "ubus": {
+ "studio": [
+ "action",
+ "mihomo",
+ "vpnconfig",
+ "mesh"
+ ]
+ },
+ "uci": [
+ "network",
+ "studio"
+ ]
+ }
+ }
+}
diff --git a/luci/luci-theme-studio/LICENSE b/luci/luci-theme-studio/LICENSE
new file mode 100644
index 0000000..f49a4e1
--- /dev/null
+++ b/luci/luci-theme-studio/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/luci/luci-theme-studio/Makefile b/luci/luci-theme-studio/Makefile
new file mode 100644
index 0000000..c77b2e5
--- /dev/null
+++ b/luci/luci-theme-studio/Makefile
@@ -0,0 +1,18 @@
+include $(TOPDIR)/rules.mk
+PKG_NAME:=luci-theme-studio
+PKG_VERSION:=1.0.0~rc1
+PKG_RELEASE:=1
+LUCI_TITLE:=OpenWrt Studio theme
+LUCI_DEPENDS:=+luci-base +luci-theme-bootstrap
+PKG_LICENSE:=Apache-2.0
+define Package/luci-theme-studio/postrm
+#!/bin/sh
+[ -n "$${IPKG_INSTROOT}" ] || {
+ if [ "$$(uci -q get luci.main.mediaurlbase)" = /luci-static/studio ]; then
+ uci set luci.main.mediaurlbase=/luci-static/bootstrap
+ fi
+ uci -q delete luci.themes.OpenWrtStudio
+ uci commit luci
+}
+endef
+include $(TOPDIR)/feeds/luci/luci.mk
diff --git a/luci/luci-theme-studio/NOTICE b/luci/luci-theme-studio/NOTICE
new file mode 100644
index 0000000..2136dbb
--- /dev/null
+++ b/luci/luci-theme-studio/NOTICE
@@ -0,0 +1,8 @@
+LuCI - Lua Configuration Interface
+Copyright 2008 Steven Barth
+Copyright 2008 Jo-Philipp Wich
+Licensed under the Apache License, Version 2.0.
+
+Contains code from:
+coxpcall - Copyright 2005 - Kepler Project (www.keplerproject.org)
+ltn12/luasocket - Copyright 2004-2007 Diego Nehab
diff --git a/luci/luci-theme-studio/htdocs/luci-static/studio/cascade.css b/luci/luci-theme-studio/htdocs/luci-static/studio/cascade.css
new file mode 100644
index 0000000..f5d5fc0
--- /dev/null
+++ b/luci/luci-theme-studio/htdocs/luci-static/studio/cascade.css
@@ -0,0 +1,2629 @@
+/*!
+ * LuCI Bootstrap Theme
+ * Copyright 2012 Nut & Bolt
+ * By David Menting
+ * Based on Bootstrap v1.4.0
+ *
+ * Copyright 2011 Twitter, Inc
+ * Licensed under the Apache License v2.0
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Designed and built with all the love in the world @twitter by @mdo and @fat.
+ */
+
+/* Variables
+ * --------- */
+
+:root {
+ --background-color-delta-l-sign: -1;
+ --background-color-h: 0;
+ --background-color-s: 0%;
+ --background-color-l: 100%;
+
+ --background-color-high-hsl:
+ var(--background-color-h),
+ var(--background-color-s),
+ var(--background-color-l);
+ --background-color-high: hsl(var(--background-color-high-hsl));
+
+ --background-color-medium-hsl:
+ var(--background-color-h),
+ var(--background-color-s),
+ calc(var(--background-color-l) + var(--background-color-delta-l-sign) * 2.35%);
+ --background-color-medium: hsl(var(--background-color-medium-hsl));
+
+ --background-color-low-hsl:
+ var(--background-color-h),
+ var(--background-color-s),
+ calc(var(--background-color-l) + var(--background-color-delta-l-sign) * 3.92%);
+ --background-color-low: hsl(var(--background-color-low-hsl));
+
+ --text-color-delta-l-sign: 1;
+ --text-color-h: 0;
+ --text-color-s: 0%;
+ --text-color-l: 0%;
+
+ --text-color-highest-hsl:
+ var(--text-color-h),
+ var(--text-color-s),
+ var(--text-color-l);
+ --text-color-highest: hsl(var(--text-color-highest-hsl));
+
+ --text-color-high-hsl:
+ var(--text-color-h),
+ var(--text-color-s),
+ calc(var(--text-color-l) + var(--text-color-delta-l-sign) * 25.1%);
+ --text-color-high: hsl(var(--text-color-high-hsl));
+
+ --text-color-medium-hsl:
+ var(--text-color-h),
+ var(--text-color-s),
+ calc(var(--text-color-l) + var(--text-color-delta-l-sign) * 50.2%);
+ --text-color-medium: hsl(var(--text-color-medium-hsl));
+
+ --text-color-low-hsl:
+ var(--text-color-h),
+ var(--text-color-s),
+ calc(var(--text-color-l) + var(--text-color-delta-l-sign) * 74.9%);
+ --text-color-low: hsl(var(--text-color-low-hsl));
+
+ --border-color-delta-l-sign: -1;
+ --border-color-h: var(--background-color-h);
+ --border-color-s: var(--background-color-s);
+ --border-color-l: var(--background-color-l);
+
+ --border-color-high-hsl:
+ var(--border-color-h),
+ var(--border-color-s),
+ calc(var(--border-color-l) + var(--border-color-delta-l-sign) * 20%);
+ --border-color-high: hsl(var(--border-color-high-hsl));
+
+ --border-color-medium-hsl:
+ var(--border-color-h),
+ var(--border-color-s),
+ calc(var(--border-color-l) + var(--border-color-delta-l-sign) * 13.33%);
+ --border-color-medium: hsl(var(--border-color-medium-hsl));
+
+ --border-color-low-hsl:
+ var(--border-color-h),
+ var(--border-color-s),
+ calc(var(--border-color-l) + var(--border-color-delta-l-sign) * 6.67%);
+ --border-color-low: hsl(var(--border-color-low-hsl));
+
+ --primary-color-high: #1976d2;
+ --primary-color-medium: #1564c0;
+ --primary-color-low: #0d46a1;
+ --on-primary-color: var(--background-color-high);
+
+ --error-color-high-rgb: 246, 43, 18;
+ --error-color-high: rgb(var(--error-color-high-rgb));
+ --error-color-medium: #e8210d;
+ --error-color-low: #d00000;
+ --on-error-color: var(--background-color-high);
+
+ --success-color-high-rgb: 0, 172, 89;
+ --success-color-high: rgb(var(--success-color-high-rgb));
+ --success-color-medium: #009a4c;
+ --success-color-low: #007936;
+ --on-success-color: var(--background-color-high);
+
+ --warn-color-high: #efbd0b;
+ --warn-color-medium: #f0c629;
+ --warn-color-low: #f2d24f;
+ --on-warn-color: var(--text-color-highest);
+
+ --disabled-opacity: .7;
+
+ color-scheme: light;
+}
+
+:root[data-darkmode="true"] {
+ --background-color-delta-l-sign: 1;
+ --background-color-h: 0;
+ --background-color-s: 0%;
+ --background-color-l: 13.33%;
+ --text-color-delta-l-sign: -1;
+ --text-color-h: 0;
+ --text-color-s: 0%;
+ --text-color-l: 100%;
+ --border-color-delta-l-sign: 1;
+
+ --primary-color-high: #4da1c0;
+ --primary-color-medium: #448da6;
+ --primary-color-low: #3c7a8d;
+
+ --error-color-high-rgb: 209, 86, 83;
+ --error-color-medium: #bf4e4c;
+ --error-color-low: #b14946;
+
+ --success-color-high-rgb: 0, 166, 108;
+ --success-color-medium: #00945e;
+ --success-color-low: #008252;
+
+ --warn-color-high: #a69461;
+ --warn-color-medium: #a68d45;
+ --warn-color-low: #a68732;
+ --on-warn-color: var(--background-color-high);
+
+ --disabled-opacity: .4;
+
+ color-scheme: dark;
+}
+
+/* Reset.less
+ * Props to Eric Meyer (meyerweb.com) for his CSS reset file. We're using an adapted version here that cuts out some of the reset HTML elements we will never need here (i.e., dfn, samp, etc).
+ * ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- */
+
+* {
+ scroll-margin-top: 40px;
+}
+
+*, *::before, *::after {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ box-sizing: border-box;
+}
+
+abbr[title], acronym[title] {
+ border-bottom: 1px dotted;
+ font-weight: inherit;
+ cursor: help;
+}
+
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
+
+ol, ul {
+ list-style: none;
+}
+
+html {
+ font-size: 100%;
+ height: 100%;
+}
+
+a:focus {
+ outline: thin dotted;
+}
+
+a:hover, a:active {
+ outline: 0;
+}
+
+sub, sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sup {
+ top: -0.5em;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+img {
+ -ms-interpolation-mode: bicubic;
+}
+
+button,
+input,
+select,
+option,
+textarea {
+ font-size: 100%;
+ margin: 0;
+ box-sizing: border-box;
+ vertical-align: baseline;
+ line-height: 2em;
+}
+
+button::-moz-focus-inner, input::-moz-focus-inner {
+ border: 0;
+ padding: 0;
+}
+
+button,
+input[type="button"],
+input[type="reset"],
+input[type="submit"] {
+ cursor: pointer;
+ -webkit-appearance: button;
+ word-break: break-all;
+}
+
+button[disabled],
+input[type="button"][disabled],
+input[type="reset"][disabled],
+input[type="submit"][disabled] {
+ opacity: var(--disabled-opacity);
+}
+
+input[type="search"] {
+ -webkit-appearance: textfield;
+ box-sizing: content-box;
+}
+
+input[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+textarea {
+ overflow: auto;
+ vertical-align: top;
+ font-family: monospace;
+}
+
+.control-group {
+ display: inline-flex;
+ gap: .2em;
+}
+
+.control-group > input + :not(input):not(select),
+.control-group > select + :not(input):not(select) {
+ margin-left: calc(-.2em + -2px);
+ border-radius: 0 3px 3px 0;
+ padding: 0 6px;
+}
+
+/*
+ * Scaffolding
+ * Basic and global styles for generating a grid system, structural layout, and page templates
+ * ------------------------------------------------------------------------------------------- */
+body {
+ background-color: var(--background-color-high);
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-size: 13px;
+ font-weight: normal;
+ line-height: 18px;
+ color: var(--text-color-high);
+ padding: 5px;
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow-y: scroll;
+}
+
+.container {
+ width: 100%;
+ max-width: 940px;
+ margin-left: auto;
+ margin-right: auto;
+ zoom: 1;
+}
+
+a {
+ color: var(--primary-color-high);
+ text-decoration: none;
+ line-height: inherit;
+ font-weight: inherit;
+}
+
+a:hover {
+ color: var(--primary-color-low);
+ text-decoration: underline;
+}
+
+.pull-right {
+ float: right;
+}
+
+.pull-left {
+ float: left;
+}
+
+.nowrap {
+ white-space: nowrap;
+}
+
+/* Typography.less
+ * Headings, body text, lists, code, and more for a versatile and durable typography system
+ * ---------------------------------------------------------------------------------------- */
+p,
+.cbi-map-descr,
+.cbi-section-descr,
+.table .tr.cbi-section-table-descr .th {
+ font-size: 13px;
+ font-weight: normal;
+ line-height: 18px;
+ margin-bottom: 9px;
+}
+
+p small {
+ font-size: 11px;
+ color: var(--text-color-low);
+}
+
+h1,
+h2,
+h3, legend,
+h4,
+h5,
+h6 {
+ font-weight: bold;
+ color: var(--text-color-high);
+}
+
+h1 small,
+h2 small,
+h3 small,
+h4 small,
+h5 small,
+h6 small {
+ color: var(--text-color-low);
+}
+
+h1 {
+ margin-bottom: 18px;
+ font-size: 30px;
+ line-height: 36px;
+}
+
+h1 small {
+ font-size: 18px;
+}
+
+h2 {
+ font-size: 24px;
+ line-height: 36px;
+}
+
+h2 small {
+ font-size: 14px;
+}
+
+h3, legend,
+h4,
+h5,
+h6 {
+ line-height: 36px;
+}
+
+h3, legend {
+ font-size: 18px;
+}
+
+h3 small {
+ font-size: 14px;
+}
+
+h4 {
+ font-size: 16px;
+}
+
+h4 small {
+ font-size: 12px;
+}
+
+h5 {
+ font-size: 14px;
+}
+
+h6 {
+ font-size: 13px;
+ color: var(--text-color-low);
+ text-transform: uppercase;
+}
+
+ul, ol {
+ margin: 0 0 18px 25px;
+}
+
+ul ul,
+ul ol,
+ol ol,
+ol ul {
+ margin-bottom: 0;
+}
+
+ul {
+ list-style: disc;
+}
+
+ol {
+ list-style: decimal;
+}
+
+li {
+ line-height: 18px;
+ color: var(--text-color-medium);
+}
+
+ul.unstyled {
+ list-style: none;
+ margin-left: 0;
+}
+
+dl {
+ margin-bottom: 18px;
+}
+
+dl dt, dl dd {
+ line-height: 18px;
+}
+
+dl dt {
+ font-weight: bold;
+}
+
+dl dd {
+ margin-left: 9px;
+}
+
+hr {
+ margin: 20px 0 19px;
+ border: 0;
+ border-bottom: 1px solid var(--border-color-low);
+}
+
+strong {
+ font-style: inherit;
+ font-weight: bold;
+}
+
+em {
+ font-style: italic;
+ font-weight: inherit;
+ line-height: inherit;
+}
+
+small { font-size: 0.9em }
+
+address {
+ display: block;
+ line-height: 18px;
+ margin-bottom: 18px;
+}
+
+code, pre {
+ padding: 0 3px 2px;
+ font-family: Monaco, Andale Mono, Courier New, monospace;
+ font-size: 12px;
+ border-radius: 3px;
+}
+
+code {
+ background-color: var(--border-color-low);
+ color: var(--text-color-high);
+ padding: 1px 3px;
+}
+
+pre {
+ --border-color-h: var(--background-color-h);
+ --border-color-s: var(--background-color-s);
+ --border-color-delta-l: 100%;
+ --border-color-l: calc(var(--background-color-l) + var(--background-color-delta-l-sign) * var(--border-color-delta-l));
+ --border-color-a: 0.15;
+ --border-color: hsla(var(--border-color-hsl), var(--border-color-a));
+
+ background-color: var(--background-color-low);
+ display: block;
+ padding: 8.5px;
+ margin: 0 0 18px;
+ line-height: 18px;
+ font-size: 12px;
+ border: 1px solid var(--border-color);
+ border-radius: 3px;
+ white-space: pre;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+/* Forms.less
+ * Base styles for various input types, form layouts, and states
+ * ------------------------------------------------------------- */
+fieldset {
+ margin-bottom: 9px;
+ padding-top: 9px;
+}
+
+fieldset legend {
+ display: block;
+ font-size: 19.5px;
+ line-height: 1;
+ color: var(--text-color-high);
+ padding-top: 20px;
+}
+
+label,
+input,
+button,
+select {
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-size: 13px;
+ font-weight: normal;
+ line-height: normal;
+}
+
+.cbi-value {
+ display: flex;
+ flex-wrap: wrap;
+ margin-bottom: 18px;
+ align-items: baseline;
+}
+
+.cbi-value-field {
+ margin-left: 20px;
+ flex: 1;
+}
+
+.cbi-value label.cbi-value-title {
+ padding-top: 6px;
+ font-size: 13px;
+ line-height: 18px;
+ flex: 0 0 180px;
+ text-align: right;
+ color: var(--text-color-high);
+}
+
+.cbi-value > .cbi-section,
+.cbi-value > .cbi-tblsection {
+ width: 100%;
+}
+
+label > input[type="checkbox"],
+label > input[type="radio"] {
+ vertical-align: text-top;
+ margin: 0;
+}
+
+label[for] {
+ cursor: pointer;
+}
+
+input,
+textarea,
+select,
+.cbi-select,
+.cbi-dropdown:not(.btn):not(.cbi-button) {
+ display: inline-block;
+ width: 210px;
+ padding: 4px;
+ background: var(--background-color-high);
+ color: var(--text-color-high);
+ font-size: 13px;
+ line-height: 18px;
+ border: 1px solid var(--border-color-high);
+ border-radius: 3px;
+}
+
+input,
+select,
+.cbi-select,
+.cbi-dropdown:not(.btn):not(.cbi-button) {
+ height: 30px;
+}
+
+.cbi-dropdown:not(.btn):not(.cbi-button),
+.cbi-dynlist {
+ min-width: 210px;
+ max-width: 400px;
+ width: auto;
+}
+
+.cbi-dynlist {
+ height: auto;
+ min-height: 30px;
+ display: inline-flex;
+ flex-direction: column;
+}
+
+.cbi-dynlist > .item {
+ margin-bottom: 4px;
+ box-shadow: 0 0 2px var(--border-color-high);
+ background: var(--background-color-high);
+ padding: 6px 2em 6px 4px;
+ border: 1px solid var(--border-color-high);
+ border-radius: 3px;
+ color: var(--text-color-high);
+ position: relative;
+ pointer-events: auto; /* needed for drag-and-drop in UIDynamicList */
+ overflow: hidden;
+ word-break: break-all;
+ cursor: move; /* drag-and-drop */
+ user-select: text; /* text selection in drag-and-drop */
+}
+
+.cbi-dynlist > .item::after {
+ content: "×";
+ position: absolute;
+ display: inline-flex;
+ align-items: center;
+ top: -1px;
+ right: -1px;
+ bottom: -1px;
+ padding: 0 6px;
+ border: 1px solid var(--border-color-high);
+ border-radius: 0 3px 3px 0;
+ font-weight: bold;
+ color: #c44;
+ pointer-events: auto;
+}
+
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .item.drag-over {
+ border-top: 1px solid var(--text-color-highest);
+}
+
+/* Make item being dragged in UIDynamicList partially transparent*/
+.cbi-dynlist > .item.dragging {
+ opacity: 0.5;
+}
+
+/* prevent pointer changing when over the span element in UIDynamicList */
+.cbi-dynlist > .item > span {
+ pointer-events: none;
+}
+
+.cbi-dynlist > .add-item {
+ display: flex;
+}
+
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .add-item > .cbi-input-text.drag-over {
+ border-top: 1px solid var(--text-color-highest);
+}
+
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .add-item > .cbi-button-add.drag-over {
+ border-top: 1px solid var(--text-color-highest);
+}
+
+.cbi-dynlist > .add-item > input,
+.cbi-dynlist > .add-item > button {
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.cbi-value-field > .cbi-checkbox,
+.cbi-value-field > div > .cbi-radio {
+ height: 30px;
+ display: inline-flex;
+ align-items: center;
+}
+
+.cbi-radio {
+ cursor: pointer;
+ gap: .125em;
+}
+
+.cbi-select {
+ padding: 0;
+ position: relative;
+ background: linear-gradient(var(--background-color-high), var(--border-color-low));
+}
+
+.cbi-select select,
+.cbi-select select:focus {
+ -webkit-appearance: none;
+ appearance: none;
+ outline: none;
+ border: none;
+ background: transparent;
+ height: 100%;
+ width: 100%;
+ padding: 0 .3em;
+ cursor: pointer;
+ margin-right: .6em;
+}
+
+.cbi-select::before {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ content: "▾";
+ padding: 0 .3em;
+ background: linear-gradient(var(--background-color-high), var(--border-color-low));
+ pointer-events: none;
+ border-radius: 3px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.cbi-select select option {
+ background: var(--background-color-low);
+ color: var(--text-color-high);
+}
+
+.cbi-select select option:hover {
+ background: var(--primary-color-low);
+ color: var(--on-primary-color);
+}
+
+.cbi-select select option:checked {
+ background: var(--primary-color-medium);
+ color: var(--on-primary-color);
+}
+
+input[type=file] {
+ padding: initial;
+ border: initial;
+ line-height: initial;
+ box-shadow: none;
+ width: auto !important;
+}
+
+input[type=button], input[type=reset], input[type=submit] {
+ width: auto;
+ height: auto;
+}
+
+select[multiple] {
+ height: inherit;
+ background-color: #fff;
+}
+
+input[type="checkbox"],
+input[type="radio"] {
+ --bd-color: var(--border-color-high);
+ --fg-color: var(--text-color-high);
+
+ appearance: none;
+ -webkit-appearance: none;
+ width: 14px;
+ height: 14px;
+ color: var(--fg-color);
+ position: relative;
+ display: inline-block;
+ cursor: pointer;
+ background: none;
+ border: none;
+}
+
+input[type="checkbox"]::before,
+input[type="checkbox"]::after,
+input[type="radio"]::before,
+input[type="radio"]::after {
+ position: absolute;
+ content: "";
+}
+
+input[type="checkbox"]::before,
+input[type="radio"]::before {
+ top: 0;
+ left: 0;
+ width: 14px;
+ height: 14px;
+ background: linear-gradient(var(--background-color-high), var(--background-color-low));
+ border: 1px solid var(--bd-color);
+ border-radius: 2px;
+}
+
+input[type="radio"],
+input[type="radio"]::before {
+ border-radius: 50%;
+}
+
+input[type="checkbox"]::after,
+input[type="radio"]::after {
+ top: 2px;
+ left: 2px;
+ width: 10px;
+ height: 10px;
+}
+
+input[type="checkbox"]:checked::after,
+input[type="radio"]:checked::after {
+ --checkmark-icon: url("data:image/svg+xml,");
+ -webkit-mask: var(--checkmark-icon) center/cover no-repeat;
+ mask: var(--checkmark-icon) center/cover no-repeat;
+ background: var(--fg-color);
+}
+
+input[type="radio"]:checked:after {
+ --checkmark-icon: url("data:image/svg+xml,");
+}
+
+input[type="checkbox"].cbi-input-invalid,
+input[type="radio"].cbi-input-invalid {
+ --bd-color: var(--error-color-medium);
+ --fg-color: var(--error-color-high);
+}
+
+.td > input[type=text],
+.td > input[type=password],
+.td > select,
+.td > .cbi-dropdown:not(.btn):not(.cbi-button),
+.cbi-dynlist > .add-item > .cbi-dropdown {
+ width: 100%;
+}
+
+::placeholder {
+ color: var(--text-color-medium);
+}
+
+.item::after,
+.btn,
+.cbi-button,
+.cbi-select,
+.cbi-dropdown,
+.cbi-dynlist > .item,
+input[type="checkbox"]::before,
+input[type="radio"]::before,
+input, button, textarea, select {
+ transition: border linear 0.2s, box-shadow linear 0.2s;
+ box-shadow: inset 0 1px 3px hsla(var(--border-color-low-hsl), .01);
+}
+
+.item:hover::after,
+.btn:hover, .btn:focus,
+.cbi-button:hover, .cbi-button:focus,
+.cbi-select.focus,
+.cbi-dropdown:focus, .cbi-dropdown[open],
+.cbi-dynlist > .item:focus,
+input[type="checkbox"]:focus::before,
+input[type="radio"]:focus::before,
+input:focus, button:hover, textarea:focus, select:focus {
+ --focus-color-rgb: 82, 168, 236;
+
+ outline: 0;
+ border-color: rgba(var(--focus-color-rgb), 0.8) !important;
+ box-shadow: inset 0 1px 3px hsla(var(--border-color-low-hsl), .01), 0 0 8px rgba(var(--focus-color-rgb), 0.6);
+ text-decoration: none;
+}
+
+.cbi-input-invalid:focus,
+.cbi-select.cbi-input-invalid,
+input[type="checkbox"].cbi-input-invalid:focus::before,
+input[type="radio"].cbi-input-invalid:focus::before {
+ --focus-color-rgb: var(--error-color-high-rgb);
+}
+
+input[disabled],
+button[disabled],
+select[disabled],
+textarea[disabled],
+.cbi-select[disabled]::before,
+.cbi-dropdown[disabled]:not(.btn):not(.cbi-button),
+input[type="checkbox"][disabled]::before,
+input[type="checkbox"][disabled]::after,
+input[type="radio"][disabled]::before,
+input[type="radio"][disabled]::after {
+ opacity: var(--disabled-opacity);
+ pointer-events: none;
+ cursor: default;
+}
+
+input[readonly],
+select[readonly],
+textarea[readonly] {
+ border-color: hsla(var(--border-color-high-hsl), var(--disabled-opacity));
+ pointer-events: auto;
+ cursor: auto;
+}
+
+.cbi-optionals,
+.cbi-section-create {
+ padding: 0 0 10px 10px;
+}
+
+.cbi-section-create {
+ margin: -3px;
+ display: inline-flex;
+ align-items: center;
+}
+
+.cbi-section-create > * {
+ margin: 3px;
+ flex: 1 1 auto;
+}
+
+.cbi-section-create > * > input {
+ width: 100%;
+}
+
+.actions,
+.cbi-page-actions {
+ background: var(--background-color-low);
+ margin-bottom: 18px;
+ padding: 17px 20px 18px 17px;
+ border-top: 1px solid var(--border-color-medium);
+ border-radius: 0 0 3px 3px;
+ text-align: right;
+}
+
+.actions .secondary-action,
+.cbi-page-actions .secondary-action{
+ float: right;
+}
+
+.actions .secondary-action a,
+.cbi-page-actions .secondary-action a {
+ line-height: 30px;
+}
+
+.actions .secondary-action a:hover,
+.cbi-page-actions .secondary-action a:hover {
+ text-decoration: underline;
+}
+
+.cbi-page-actions > form {
+ display: inline;
+ margin: 0;
+}
+
+/*
+ * Tables.less
+ * Tables for, you guessed it, tabular data
+ * ---------------------------------------- */
+.tr { display: table-row; }
+.table[width="33%"], .th[width="33%"], .td[width="33%"] { width: 33%; }
+.table[width="100%"], .th[width="100%"], .td[width="100%"] { width: 100%; }
+
+.table {
+ display: table;
+ width: 100%;
+ margin-bottom: 18px;
+ padding: 0;
+ font-size: 13px;
+ border-collapse: collapse;
+ position: relative;
+}
+
+.table .th, .table .td {
+ display: table-cell;
+ vertical-align: middle; /* Fixme */
+ padding: 10px 10px 9px;
+ line-height: 18px;
+ text-align: left;
+}
+
+.table .tr:first-child .th {
+ padding-top: 9px;
+ font-weight: bold;
+ vertical-align: top;
+}
+
+.table .td, .table .th {
+ border-top: 1px solid var(--border-color-medium);
+}
+
+.tr.placeholder {
+ height: calc(3em + 20px);
+}
+
+.tr.placeholder > .td {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ text-align: center;
+ line-height: 3em;
+}
+
+.tr.drag-over-above,
+.tr.drag-over-below {
+ border: 2px solid #0069d6;
+ border-width: 2px 0 0 0;
+}
+
+.tr.drag-over-below {
+ border-width: 0 0 2px 0;
+}
+
+/* Patterns.less
+ * Repeatable UI elements outside the base styles provided from the scaffolding
+ * ---------------------------------------------------------------------------- */
+header {
+ position: sticky;
+ top: 0;
+ z-index: 800;
+ overflow: visible;
+ color: #BFBFBF;
+ margin: -5px -5px 15px -5px;
+ display: flex;
+}
+
+header a {
+ color: #bfbfbf;
+ text-shadow: 0 -1px 0 hsla(var(--border-color-low-hsl), .25);
+}
+
+header .brand:hover, header ul .active > a {
+ background-color: #333;
+ background-color: rgba(255, 255, 255, 0.05);
+ color: #fff;
+ text-decoration: none;
+}
+
+header .brand {
+ float: left;
+ display: block;
+ padding: 8px 20px 12px;
+ margin-left: -15px;
+ color: #fff;
+ font-size: 20px;
+ font-weight: 200;
+ line-height: 1;
+}
+
+header .pull-right {
+ margin-left: auto;
+ margin-right: 5px;
+ align-self: center;
+}
+
+header p {
+ margin: 0;
+ line-height: 40px;
+}
+
+header {
+ background: linear-gradient(#333333, #222222);
+ box-shadow: 0 1px 3px hsla(var(--border-color-low-hsl), .25), inset 0 -1px 0 hsla(var(--border-color-low-hsl), .01);
+ padding: 0;
+ /* the redundant properties below work around a csstidy bug */
+ padding-left: calc((100% - 940px) / 2);
+ padding-right: calc((100% - 940px) / 2);
+}
+
+.nav {
+ display: block;
+ float: left;
+ margin: 0 10px 0 0;
+ position: relative;
+ left: 0;
+}
+
+.nav > li {
+ display: block;
+ float: left;
+}
+
+.nav a {
+ display: block;
+ float: none;
+ padding: 10px 10px 11px;
+ line-height: 19px;
+ text-decoration: none;
+}
+
+.nav a:hover {
+ color: #fff;
+ text-decoration: none;
+}
+
+.nav .active > a {
+ background-color: #222;
+ background-color: rgba(0, 0, 0, 0.5);
+}
+
+.nav a.menu:hover {
+ background: #444;
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.nav .dropdown-menu {
+ background-color: #333;
+}
+
+.nav .dropdown-menu a.menu {
+ color: #fff;
+}
+
+.nav .dropdown-menu li a {
+ color: #999;
+ text-shadow: 0 1px 0 rgba(0, 0, 0, 0.5);
+}
+
+.nav .dropdown-menu li a:hover {
+ background-color: #191919;
+ background-repeat: repeat-x;
+ background-image: linear-gradient(to bottom, #292929, #191919);
+ color: #fff;
+}
+
+.nav .dropdown-menu .active a {
+ color: #fff;
+}
+
+.nav .dropdown-menu li a {
+ padding: 4px 15px;
+}
+
+li.menu, .dropdown {
+ position: relative;
+}
+
+a.menu:after {
+ width: 0;
+ height: 0;
+ display: inline-block;
+ content: "↓";
+ text-indent: -99999px;
+ vertical-align: top;
+ margin-top: 8px;
+ margin-left: 4px;
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-top: 4px solid #fff;
+ opacity: 0.5;
+}
+
+.dropdown-menu {
+ background-color: #fff;
+ float: left;
+ position: absolute;
+ top: 40px;
+ left: -9999px;
+ z-index: 900;
+ min-width: max-content;
+ margin-left: 0;
+ margin-right: 0;
+ padding: 6px 0;
+ zoom: 1;
+ border-color: #999;
+ border-color: rgba(0, 0, 0, 0.2);
+ border-style: solid;
+ border-width: 0 1px 1px;
+ border-radius: 0 0 6px 6px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+ background-clip: padding-box;
+}
+
+.dropdown-menu li {
+ float: none;
+ display: block;
+ background-color: transparent;
+}
+
+.dropdown-menu a {
+ display: block;
+ padding: 4px 15px;
+ clear: both;
+ font-weight: normal;
+ line-height: 18px;
+ color: #808080;
+ text-shadow: 0 1px 0 #fff;
+}
+
+.dropdown-menu a:hover {
+ background-color: #ddd;
+ background-repeat: repeat-x;
+ background-image: linear-gradient(to bottom, #eee, #ddd);
+ color: #404040;
+ text-decoration: none;
+ box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.025), inset 0 -1px rgba(0, 0, 0, 0.025);
+}
+
+.dropdown:hover ul.dropdown-menu {
+ left: 0;
+}
+
+.dropdown-menu .dropdown-menu {
+ position: absolute;
+ left: 159px;
+}
+
+.dropdown-menu li {
+ position: relative;
+}
+
+.tabs, .cbi-tabmenu {
+ --tab-bar-background-color: var(--background-color-high);
+ --tab-inactive-background-color-h: var(--border-color-low-h);
+ --tab-inactive-background-color-s: var(--border-color-low-s);
+ --tab-inactive-background-color-l: var(--border-color-low-l);
+ --tab-inactive-background-color: var(--border-color-low);
+ --tab-inactive-border-color: var(--border-color-medium);
+ --tab-inactive-text-color-delta-l: 33.33%;
+ --tab-inactive-text-color-l: calc(var(--tab-inactive-background-color-l) + var(--background-color-delta-l-sign) * var(--tab-inactive-text-color-delta-l));
+ --tab-inactive-text-color: hsl(var(--tab-inactive-background-color-hsl));
+ --tab-inactive-hover-background-color: var(--background-color-high);
+ --tab-active-background-color: var(--background-color-high);
+ --tab-active-text-color: #0069d6;
+ --tab-active-border-color: var(--border-color-medium);
+
+ margin: 0 -5px 18px;
+ padding: 0 2px;
+ list-style: none;
+ display: flex;
+ flex-wrap: wrap;
+ background: linear-gradient(var(--tab-bar-background-color) 28px, var(--tab-inactive-border-color) 28px);
+ background-size: 1px 29px;
+ background-position: left bottom;
+}
+
+.tabs > li, .cbi-tabmenu > li {
+ flex: 0 1 auto;
+ display: flex;
+ align-items: center;
+ height: 25px;
+ max-width: 48%;
+ margin: 4px 2px 0 2px;
+ background: var(--tab-active-background-color);
+ border: 1px solid var(--tab-active-border-color);
+ border-bottom: none;
+ border-radius: 4px 4px 0 0;
+ color: var(--primary-color-high);
+}
+
+.tabs > li > a, .cbi-tabmenu > li > a {
+ padding: 4px 6px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: inherit;
+ text-decoration: none;
+ border-radius: 4px 4px 0 0;
+ line-height: 25px;
+ outline: none;
+}
+
+.tabs > li > a:focus-visible, .cbi-tabmenu > li > a:focus-visible {
+ text-decoration: underline;
+}
+
+.tabs > li:not(.active):hover, .cbi-tabmenu > .cbi-tab-disabled:hover {
+ background: linear-gradient(var(--tab-inactive-hover-background-color) 90%, var(--tab-inactive-border-color) 100%);
+}
+
+.tabs > li:not(.active), .cbi-tabmenu > .cbi-tab-disabled {
+ color: var(--tab-inactive-text-color);
+ background: linear-gradient(var(--tab-inactive-background-color) 90%, var(--tab-inactive-border-color) 100%);
+}
+
+.cbi-tab-disabled[data-errors]::after {
+ content: attr(data-errors);
+ background: #c43c35;
+ color: #fff;
+ height: 16px;
+ min-width: 16px;
+ border-radius: 8px;
+ text-align: center;
+ margin: 0 5px 0 0;
+ padding: 3px 2px 1px 2px;
+ display: inline-flex;
+ flex-direction: column;
+ justify-content: center;
+ font-size: 12px;
+}
+
+.cbi-tabmenu.map {
+ margin: 0;
+}
+
+.cbi-tabmenu.map > li {
+ font-size: 16.5px;
+ font-weight: bold;
+}
+
+.cbi-tab-descr {
+ margin: -9px 0 18px 0;
+}
+
+.tabs .menu-dropdown, .tabs .dropdown-menu {
+ top: 35px;
+ border-width: 1px;
+ border-radius: 0 6px 6px 6px;
+}
+
+.tabs a.menu:after, .tabs .dropdown-toggle:after {
+ border-top-color: #999;
+ margin-top: 15px;
+ margin-left: 5px;
+}
+
+.tabs li.open.menu .menu, .tabs .open.dropdown .dropdown-toggle {
+ border-color: #999;
+}
+
+.tabs li.open a.menu:after, .tabs .dropdown.open .dropdown-toggle:after {
+ border-top-color: #555;
+}
+
+.tab-content > .tab-pane,
+.tab-content > div {
+ display: none;
+}
+
+.tab-content > .active {
+ display: block;
+}
+
+.breadcrumb {
+ padding: 7px 14px;
+ margin: 0 0 18px;
+ background: linear-gradient(to bottom, var(--background-color-high), var(--background-color-low));
+ border: 1px solid var(--border-color-medium);
+ border-radius: 3px;
+ display: flex;
+ flex: 0;
+}
+
+.breadcrumb li {
+ list-style: none;
+}
+
+.breadcrumb li:not(:last-child)::after {
+ content: "|";
+ padding: 0 5px;
+}
+
+.breadcrumb .active a {
+ color: var(--text-color-medium);
+}
+
+footer {
+ padding-top: 17px;
+ margin-top: auto;
+ border-top: 1px solid var(--border-color-low);
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ font-size: 12px;
+ color: var(--text-color-medium);
+ /* the redundant properties below work around a csstidy bug */
+ padding-left: calc((100% - 940px) / 2);
+ padding-right: calc((100% - 940px) / 2);
+}
+
+footer a {
+ color: var(--primary-color-low);
+}
+
+footer span {
+ margin-bottom: 1em;
+}
+
+footer ul.breadcrumb {
+ margin-left: auto;
+}
+
+#modal_overlay {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ z-index: 900;
+ overflow: auto;
+ transition: opacity .125s ease-in;
+ opacity: 0;
+ visibility: hidden;
+}
+
+.modal {
+ width: 90%;
+ margin: 5em auto;
+ min-height: 32px;
+ max-width: 600px;
+ border-radius: 3px;
+ background: var(--background-color-high);
+ border: 1px solid var(--border-color-low);
+ box-shadow: 0 0 3px var(--background-color-low);
+ padding: 1em 1em .5em 1em;
+ min-width: 270px;
+}
+
+.modal > * {
+ line-height: normal;
+ margin-bottom: .5em;
+ max-width: 100%;
+}
+
+.modal > pre,
+.modal > textarea {
+ white-space: pre-wrap;
+ overflow: auto;
+ width: 100%;
+}
+
+.modal > .button-row {
+ display: flex;
+ justify-content: space-between;
+}
+
+.modal > .button-row > button:not(:last-of-type) {
+ margin-right: .5em;
+}
+
+.modal > .button-row > button:first-of-type {
+ margin-right: auto;
+}
+
+body.modal-overlay-active {
+ overflow: hidden;
+ height: 100vh;
+}
+
+body.modal-overlay-active #modal_overlay {
+ left: 0;
+ right: 0;
+ opacity: 1;
+ visibility: visible;
+}
+
+.btn .close, .alert-message .close {
+ font-family: Arial, sans-serif;
+ line-height: 18px;
+}
+
+.btn.danger,
+.alert-message.danger,
+.btn.error,
+.alert-message.error,
+.cbi-tooltip.error {
+ background: linear-gradient(var(--error-color-low), var(--error-color-medium));
+ color: var(--on-error-color);
+ border-color: var(--error-color-high) var(--error-color-high) var(--error-color-low);
+}
+
+.btn.success, .alert-message.success, .cbi-tooltip.success {
+ background: linear-gradient(var(--success-color-low), var(--success-color-medium));
+ color: var(--on-error-color);
+ border-color: var(--success-color-high) var(--success-color-high) var(--success-color-low);
+}
+
+.btn.info, .alert-message.info, .cbi-tooltip.info {
+ background: linear-gradient(var(--primary-color-low), var(--primary-color-medium));
+ color: var(--on-primary-color);
+ border-color: var(--primary-color-high) var(--primary-color-high) var(--primary-color-low);
+}
+
+.alert-message.notice, .cbi-tooltip.notice {
+ background: linear-gradient(var(--background-color-low), var(--background-color-medium));
+ border-color: var(--background-color-high) var(--background-color-high) var(--background-color-low);
+ color: var(--text-color-high);
+}
+
+.alert-message.warning {
+ background: linear-gradient(var(--warn-color-low), var(--warn-color-medium));
+ border-color: var(--warn-color-high) var(--warn-color-high) var(--warn-color-low);
+ color: var(--on-warn-color);
+}
+
+.modal.alert-message {
+ color: var(--text-color-high);
+}
+
+.item::after,
+.btn,
+.cbi-button {
+ --default-btn-background: linear-gradient(var(--background-color-high), var(--background-color-high) 25%, var(--background-color-low));
+ --on-color: var(--text-color-high);
+
+ cursor: pointer;
+ display: inline-block;
+ background: var(--default-btn-background);
+ padding: 0 14px;
+ color: var(--on-color);
+ font-size: 13px;
+ line-height: 2em;
+ border: 1px solid var(--border-color-high);
+ border-radius: 4px;
+ white-space: pre;
+}
+
+.cbi-input-invalid,
+.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
+.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li,
+.cbi-value-error input {
+ color: var(--error-color-high);
+ border-color: var(--error-color-medium);
+}
+
+.cbi-button-positive,
+.cbi-button-fieldadd,
+.cbi-button-add,
+.cbi-button-save {
+ --on-color: var(--success-color-high);
+ border-color: var(--on-color);
+}
+
+.cbi-button-neutral,
+.cbi-button-download,
+.cbi-button-find,
+.cbi-button-link,
+.cbi-button-up,
+.cbi-button-down {
+ color: var(--text-color-high);
+}
+
+.btn.primary,
+.cbi-button-action,
+.cbi-button-apply,
+.cbi-button-reload,
+.cbi-button-edit {
+ --on-color: var(--primary-color-high);
+ border-color: var(--on-color);
+}
+
+.cbi-button-negative,
+.cbi-section-remove .cbi-button,
+.cbi-button-reset,
+.cbi-button-remove {
+ --on-color: var(--error-color-high);
+ border-color: var(--on-color);
+}
+
+.cbi-page-actions::after {
+ display: table;
+ content: "";
+ clear: both;
+}
+
+.cbi-page-actions > * {
+ vertical-align: middle;
+}
+
+.cbi-page-actions > :not([method="post"]):not(.cbi-button-apply):not(.cbi-button-negative):not(.cbi-button-save):not(.cbi-button-reset) {
+ float: left;
+ margin-right: .4em;
+}
+
+.btn.primary,
+.cbi-button-action.important,
+.cbi-page-actions .cbi-button-apply,
+.cbi-section-actions .cbi-button-edit {
+ --on-color: var(--on-primary-color);
+ background: linear-gradient(var(--primary-color-medium), var(--primary-color-low));
+ border-color: var(--primary-color-high);
+}
+
+.cbi-button-positive.important,
+.cbi-page-actions .cbi-button-save {
+ --on-color: var(--on-success-color);
+ background: linear-gradient(var(--success-color-medium), var(--success-color-low));
+ border-color: var(--success-color-high);
+}
+
+.cbi-button-negative.important {
+ --on-color: var(--on-error-color);
+ background: linear-gradient(var(--error-color-medium), var(--error-color-low));
+ border-color: var(--error-color-high);
+}
+
+.cbi-page-actions .cbi-button-apply + .cbi-button-save,
+.cbi-page-actions .cbi-button-negative + .cbi-button-save {
+ --on-color: var(--success-color-high);
+ border-color: var(--on-color);
+ background: var(--default-btn-background);
+}
+
+.cbi-dropdown {
+ display: inline-flex !important;
+ cursor: pointer;
+ height: auto;
+ position: relative;
+ padding: 0 !important;
+}
+
+.cbi-dropdown:not(.btn):not(.cbi-button) {
+ background: linear-gradient(var(--background-color-high) 0%, var(--border-color-low) 100%);
+ border: 1px solid var(--border-color-high);
+ border-radius: 3px;
+ color: var(--text-color-high);
+}
+
+.cbi-dropdown img {
+ /* the following are necessary to constrain (vector) images of any size */
+ max-height: 1.6em; /* Adjusts image height to fit slightly larger than text line height */
+ width: auto; /* Preserves aspect ratio */
+}
+
+.cbi-dropdown > ul {
+ margin: 0 !important;
+ padding: 0;
+ list-style: none;
+ overflow-x: hidden;
+ overflow-y: hidden;
+ display: flex;
+ width: 100%;
+}
+
+.cbi-dropdown.btn > ul:not(.dropdown),
+.cbi-dropdown.cbi-button > ul:not(.dropdown) {
+ margin: 0 0 0 13px !important;
+}
+
+.cbi-dropdown.btn.spinning > ul:not(.dropdown),
+.cbi-dropdown.cbi-button.spinning > ul:not(.dropdown) {
+ margin: 0 !important;
+}
+
+.cbi-dropdown > ul.preview {
+ display: none;
+}
+
+.cbi-dropdown > .open,
+.cbi-dropdown > .more {
+ flex-grow: 0;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ text-align: center;
+ line-height: 2em;
+ padding: 0 .3em;
+}
+
+.cbi-dropdown.btn > .open,
+.cbi-dropdown.cbi-button > .open {
+ padding: 0 .5em;
+ margin-left: .5em;
+ border-left: 1px solid;
+}
+
+.cbi-dropdown > .more,
+.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[placeholder] {
+ color: var(--text-color-medium);
+ display: none;
+}
+
+.cbi-dropdown > ul > li {
+ display: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex-shrink: 1;
+ flex-grow: 1;
+ align-items: center;
+ align-self: center;
+ color: inherit;
+}
+
+.cbi-dropdown > ul.dropdown > li,
+.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li {
+ min-height: 20px;
+ padding: .25em;
+ color: var(--text-color-high);
+}
+
+.cbi-dropdown > ul > li .hide-open { display: block; display: initial; }
+.cbi-dropdown > ul > li .hide-close { display: none; }
+
+.cbi-dropdown > ul > li[display]:not([display="0"]) {
+ border-left: 1px solid var(--border-color-high);
+}
+
+.cbi-dropdown[empty] > ul {
+ max-width: 1px;
+}
+
+.cbi-dropdown > ul > li > form {
+ display: none;
+ margin: 0;
+ padding: 0;
+ pointer-events: none;
+}
+
+.cbi-dropdown > ul > li img {
+ vertical-align: middle;
+ margin-right: .25em;
+}
+
+.cbi-dropdown > ul > li > form > input[type="checkbox"] {
+ margin: 0 .25em 0 0;
+}
+
+.cbi-dropdown > ul > li input[type="text"] {
+ height: 20px;
+}
+
+.cbi-dropdown[open] {
+ position: relative;
+}
+
+.cbi-dropdown[open] > ul.dropdown {
+ display: block;
+ background: var(--background-color-low);
+ border: 1px solid #918e8c;
+ box-shadow: 0 0 4px #918e8c;
+ position: absolute;
+ z-index: 1100;
+ max-width: none;
+ min-width: 100%;
+ width: auto;
+ transition: max-height .125s ease-in;
+ overflow-y: auto;
+}
+
+.cbi-dropdown > ul > li[display],
+.cbi-dropdown[open] > ul.preview,
+.cbi-dropdown[open] > ul.dropdown > li,
+.cbi-dropdown[multiple] > ul > li > label,
+.cbi-dropdown[multiple][open] > ul.dropdown > li,
+.cbi-dropdown[multiple][more] > .more,
+.cbi-dropdown[multiple][empty] > .more {
+ flex-grow: 1;
+ display: flex !important;
+}
+
+.cbi-dropdown[empty] > ul > li,
+.cbi-dropdown[optional][open] > ul.dropdown > li[placeholder] {
+ display: block !important;
+}
+
+.cbi-dropdown[multiple][open] > ul.dropdown > li > form {
+ display: flex !important;
+}
+
+.cbi-dropdown[open] > ul.dropdown > li .hide-open { display: none; }
+.cbi-dropdown[open] > ul.dropdown > li .hide-close { display: block; display: initial; }
+
+.cbi-dropdown[open] > ul.dropdown > li {
+ border-bottom: 1px solid var(--border-color-high);
+}
+
+.cbi-dropdown[open] > ul.dropdown > li[selected] {
+ background: var(--primary-color-medium);
+ color: var(--on-primary-color);
+}
+
+.cbi-dropdown[open] > ul.dropdown > li.focus,
+.cbi-dropdown[open] > ul.dropdown > li:hover {
+ background: var(--primary-color-low);
+ color: var(--on-primary-color);
+}
+
+.cbi-dropdown[open] > ul.dropdown > li[selected].focus,
+.cbi-dropdown[open] > ul.dropdown > li[selected]:hover {
+ background: linear-gradient(90deg, var(--primary-color-medium) 50%, var(--primary-color-low));
+}
+
+.cbi-dropdown[open] > ul.dropdown > li:last-child {
+ margin-bottom: 0;
+ border-bottom: none;
+}
+
+.cbi-dropdown[open] > ul.dropdown > li[unselectable] {
+ opacity: 0.7;
+}
+
+.cbi-dropdown[open] > ul.dropdown > li > input.create-item-input:first-child:last-child {
+ width: 100%;
+}
+
+.cbi-title-ref {
+ color: #37c;
+}
+
+.cbi-title-ref::after {
+ content: "➙";
+}
+
+.cbi-tooltip-container {
+ cursor: help;
+}
+
+.cbi-tooltip {
+ position: absolute;
+ z-index: 1000;
+ left: -10000px;
+ box-shadow: 0 0 2px var(--border-color-high);
+ border-radius: 3px;
+ background: var(--background-color-high);
+ white-space: pre;
+ padding: 2px 5px;
+ opacity: 0;
+ transition: opacity .25s ease-in;
+}
+
+.cbi-tooltip-container:hover .cbi-tooltip:not(:empty) {
+ left: auto;
+ opacity: 1;
+ transition: opacity .25s ease-in;
+}
+
+.cbi-progressbar {
+ border: 1px solid var(--border-color-high);
+ border-radius: 3px;
+ position: relative;
+ min-width: 170px;
+ height: 8px;
+ margin: 1.4em 0 4px 0;
+ background: var(--background-color-medium);
+}
+
+.cbi-progressbar > div {
+ background: var(--primary-color-medium);
+ height: 100%;
+ transition: width .25s ease-in;
+ width: 0%;
+ border-radius: 2px;
+}
+
+.cbi-progressbar::before {
+ position: absolute;
+ top: -1.4em;
+ left: 0;
+ content: attr(title);
+ white-space: pre;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.zonebadge .cbi-tooltip {
+ padding: 1px;
+ background: inherit;
+ margin: -1.6em 0 0 -5px;
+ border-radius: 3px;
+ pointer-events: none;
+ box-shadow: 0 0 3px #444;
+}
+
+.zonebadge .cbi-tooltip > * {
+ margin: 1px;
+ z-index: 2;
+ position: relative;
+}
+
+.zone-forwards {
+ display: flex;
+ flex-wrap: nowrap;
+}
+
+.zone-forwards > * {
+ flex: 1 1 40%;
+ padding: 1px;
+ text-wrap: auto;
+}
+
+.zone-forwards > span {
+ flex-basis: 10%;
+ text-align: center;
+}
+
+.zone-forwards .zone-src,
+.zone-forwards .zone-dest {
+ display: flex;
+ flex-direction: column;
+}
+
+.btn.active, .btn:active {
+ box-shadow: inset 0 2px 4px hsla(var(--border-color-low-hsl), .25), 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+
+.btn.disabled,
+.btn[disabled] {
+ cursor: default;
+ opacity: var(--disabled-opacity);
+ box-shadow: none;
+}
+
+.btn.large {
+ font-size: 15px;
+ line-height: normal;
+ padding: 9px 14px 9px;
+ border-radius: 6px;
+}
+
+.btn.small {
+ padding: 7px 9px 7px;
+ font-size: 11px;
+}
+
+button.btn::-moz-focus-inner, input[type=submit].btn::-moz-focus-inner {
+ padding: 0;
+ border: 0;
+}
+
+.close {
+ float: right;
+ color: #000;
+ font-size: 20px;
+ font-weight: bold;
+ line-height: 13.5px;
+ text-shadow: 0 1px 0 #fff;
+ opacity: 0.25;
+}
+
+.close:hover {
+ color: #000;
+ text-decoration: none;
+ opacity: 0.4;
+}
+
+.alert-message {
+ position: relative;
+ padding: .5em .5em .25em .5em;
+ margin-bottom: .5em;
+ color: var(--on-warn-color);
+ background: linear-gradient(#fceec1, #eedc94);
+ border: 1px solid var(--border-color-low);
+ border-color: var(--border-color-high) var(--border-color-high) var(--border-color-low);
+ border-radius: 4px;
+ box-shadow: 1px 1px 1px var(--border-color-low);
+ white-space: unset;
+}
+
+.alert-message .close {
+ margin-top: 1px;
+}
+
+.alert-message h4,
+.alert-message h5,
+.alert-message pre,
+.alert-message ul,
+.alert-message li,
+.alert-message p {
+ color: inherit;
+ border: none;
+ line-height: inherit;
+ background: transparent;
+ padding: 0;
+ margin: .25em 0;
+}
+
+.alert-message button {
+ margin: .25em 0;
+}
+
+.label,
+header [data-indicator] {
+ padding: 3px 3px 2px;
+ font-size: 9.75px;
+ font-weight: bold;
+ color: var(--text-color-high);
+ text-transform: uppercase;
+ white-space: nowrap;
+ background-color: var(--background-color-low);
+ border-radius: 3px;
+ text-shadow: none;
+ margin: .125em 0 .125em .4em;
+}
+
+header [data-indicator][data-clickable] {
+ cursor: pointer;
+}
+
+a.label:link,
+a.label:visited {
+ color: #fff;
+}
+
+a.label:hover {
+ text-decoration: none;
+}
+
+.label.important {
+ background-color: var(--primary-color-high);
+ color: var(--on-primary-color);
+}
+
+.label.warning {
+ background-color: var(--warn-color-high);
+ color: var(--on-warn-color);
+}
+
+.label.success {
+ background-color: var(--success-color-high);
+ color: var(--on-success-color);
+}
+
+.label.notice,
+header [data-indicator][data-style="active"] {
+ color: var(--on-primary-color);
+ background-color: var(--primary-color-high);
+}
+
+/* LuCI specific items */
+.hidden { display: none }
+
+form.inline { display: inline; margin-bottom: 0; }
+
+#modemenu li:last-child span.divider { display: none }
+
+#syslog {
+ width: 100%;
+ color: var(--text-color-highest);
+ margin-bottom: 18px;
+ font-family: monospace;
+}
+
+.cbi-section-table .tr:hover .td,
+.cbi-section-table .tr:hover .th,
+.cbi-section-table .tr:hover::before {
+ background-color: var(--background-color-low);
+}
+
+.cbi-section-table .tr.cbi-section-table-descr .th {
+ font-weight: normal;
+}
+
+.cbi-section-table-titles.named::before,
+.cbi-section-table-descr.named::before,
+.cbi-section-table-row[data-title]::before {
+ content: attr(data-title) " ";
+ display: table-cell;
+ padding: 10px 10px 9px;
+ line-height: 18px;
+ font-weight: bold;
+ vertical-align: middle;
+}
+
+.cbi-section-table-titles.named::before,
+.cbi-section-table-descr.named::before,
+.cbi-section-table-row[data-title]::before {
+ border-top: 1px solid var(--border-color-medium);
+}
+
+.left { text-align: left !important; }
+.right { text-align: right !important; }
+.center { text-align: center !important; }
+.top { vertical-align: top !important; }
+.middle { vertical-align: middle !important; }
+.bottom { vertical-align: bottom !important; }
+
+table table td,
+.cbi-value-field table td {
+ border: none;
+}
+
+.table.cbi-section-table input[type="password"],
+.table.cbi-section-table input[type="text"],
+.table.cbi-section-table textarea,
+.table.cbi-section-table select,
+.table.cbi-section-table .cbi-select {
+ width: 100%;
+ min-width: auto;
+}
+
+.table.cbi-section-table .td.cbi-section-table-cell {
+ white-space: nowrap;
+ text-align: right;
+}
+
+.table.cbi-section-table .td.cbi-section-table-cell select,
+.table.cbi-section-table .td.cbi-section-table-cell .cbi-select {
+ width: inherit;
+}
+
+.td.cbi-section-actions {
+ text-align: right;
+ vertical-align: middle;
+ width: 15%;
+}
+
+.td.cbi-section-actions > * {
+ display: flex;
+}
+
+.td.cbi-section-actions > :not(.cbi-dropdown) > *,
+.td.cbi-section-actions > * > form > * {
+ flex: 1 1 4em;
+ margin: 0 1px;
+}
+
+.td.cbi-section-actions > * > form {
+ display: inline-flex;
+ margin: 0;
+}
+
+.table.valign-middle .td {
+ vertical-align: middle;
+}
+
+.cbi-rowstyle-2,
+.tr.table-titles,
+.tr.cbi-section-table-titles {
+ background: var(--background-color-medium);
+}
+
+th[data-sortable-row] { cursor: pointer; }
+th[data-sort-direction="asc"]::after { content: "\a0\25b2"; }
+th[data-sort-direction="desc"]::after { content: "\a0\25bc"; }
+
+.cbi-value-description {
+ margin: .25em 0 0 1.25em;
+ position: relative;
+}
+
+.cbi-value-description:not(:empty)::before {
+ --help-icon: url("data:image/svg+xml,");
+
+ position: absolute;
+ left: -1.25em;
+ content: "\a0";
+ display: inline-block;
+ width: 1em;
+ height: 1em;
+ margin-right: .25em;
+ background: var(--primary-color-high);
+ mask-image: var(--help-icon);
+ mask-size: cover;
+ -webkit-mask-image: var(--help-icon);
+ -webkit-mask-size: cover;
+}
+
+.cbi-section-error {
+ border: 1px solid #f00;
+ border-radius: 3px;
+ background-color: #fce6e6;
+ padding: 5px;
+ margin-bottom: 18px;
+}
+
+.cbi-section-error ul { margin: 0 0 0 20px; }
+
+.cbi-section-error ul li {
+ color: #f00;
+ font-weight: bold;
+}
+
+.ifacebox {
+ border: 1px solid var(--border-color-high);
+ margin: 0 10px;
+ text-align: center;
+ white-space: nowrap;
+ background: linear-gradient(var(--background-color-high), var(--background-color-medium));
+ border-radius: 4px;
+ box-shadow: inset 0 1px 0 hsla(var(--text-color-low-hsl), 0.05);
+ display: inline-flex;
+ flex-direction: column;
+ line-height: 1.2em;
+ min-width: 100px;
+}
+
+.ifacebox .ifacebox-head {
+ border-bottom: 1px solid var(--border-color-high);
+ border-radius: 3px 3px 0 0;
+ padding: 2px;
+ background: #eee;
+ color: var(--text-color-high);
+}
+
+.ifacebox .ifacebox-head[style] {
+ text-shadow: 0 1px 1px hsla(var(--background-color-high-hsl), 0.75);
+}
+
+.ifacebox .ifacebox-head.active {
+ background: var(--primary-color-high);
+ color: var(--on-primary-color);
+}
+
+[data-darkmode="true"] .zonebadge[style],
+[data-darkmode="true"] .ifacebox-head[style] {
+ background: linear-gradient(rgba(var(--zone-color-rgb), .4), rgba(var(--zone-color-rgb), .3)) !important;
+}
+
+.ifacebox .ifacebox-body {
+ padding: .25em;
+}
+
+#cbi-network-interface .ifacebox-body {
+ white-space: normal;
+}
+
+.ifacebadge {
+ display: inline-block;
+ flex-direction: row;
+ white-space: nowrap;
+ border: 1px solid var(--border-color-high);
+ padding: 2px;
+ background: linear-gradient(var(--background-color-high), var(--background-color-high) 25%, var(--background-color-medium));
+ color: var(--text-color-high);
+ text-shadow: 0 1px 1px hsla(var(--background-color-high-hsl), 0.75);
+ border-radius: 4px;
+ box-shadow: inset 0 1px 0 hsla(var(--background-color-high-hsl), 0.05);
+ cursor: default;
+ line-height: 1.2em;
+}
+
+.ifacebadge img {
+ width: 32px;
+ height: 32px;
+ vertical-align: middle;
+}
+
+.ifacebadge-active {
+ border-color: #000;
+ font-weight: bold;
+}
+
+.network-status-table {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.network-status-table .ifacebox {
+ margin: .5em;
+ flex-grow: 1;
+}
+
+.network-status-table .ifacebox-body {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ text-align: left;
+}
+
+.network-status-table .ifacebox-body > * {
+ margin: .25em;
+}
+
+.network-status-table .ifacebox-body > span {
+ flex: 10 10 auto;
+ height: 100%;
+}
+
+.network-status-table .ifacebox-body > div {
+ margin: -.125em;
+ display: flex;
+ flex-wrap: wrap;
+}
+
+#dsl_status_table .ifacebox-body span > strong {
+ display: inline-block;
+ min-width: 35%;
+}
+
+.ifacebadge.large,
+.network-status-table .ifacebox-body .ifacebadge {
+ display: flex;
+ flex: 1;
+ padding: .25em;
+ min-width: 220px;
+ margin: .125em;
+}
+
+.ifacebadge.large {
+ display: inline-flex;
+}
+
+.network-status-table .ifacebox-body .ifacebadge > span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.ifacebadge > *,
+.ifacebadge.large > * {
+ margin: 0 .125em;
+}
+
+.zonebadge {
+ padding: 2px;
+ border-radius: 4px;
+ display: inline-block;
+ white-space: nowrap;
+ color: var(--text-color-high);
+ text-shadow: 0 1px 1px hsla(var(--background-color-high-hsl), 0.75);
+}
+
+.zonebadge > em,
+.zonebadge > strong {
+ margin: 0 2px;
+ display: inline-block;
+}
+
+.zonebadge input {
+ width: 6em;
+}
+
+.zonebadge > .ifacebadge {
+ margin-left: 2px;
+}
+
+.zonebadge-empty {
+ border: 1px dashed #aaa;
+ color: #aaa;
+ font-style: italic;
+ font-size: smaller;
+}
+
+div.cbi-value var,
+.td.cbi-value-field var {
+ font-style: italic;
+ color: #0069d6;
+}
+
+div.cbi-value var[data-tooltip],
+.td.cbi-value-field var[data-tooltip],
+div.cbi-value var.cbi-tooltip-container,
+.td.cbi-value-field var.cbi-tooltip-container {
+ cursor: help;
+ border-bottom: 1px dotted #0069d6;
+}
+
+div.cbi-value var.cbi-tooltip-container,
+.td.cbi-value-field var.cbi-tooltip-container .cbi-tooltip {
+ font-style: normal;
+ white-space: normal;
+ color: var(--text-color-high);
+}
+
+#modal_overlay > .modal.uci-dialog,
+#modal_overlay > .modal.cbi-modal {
+ max-width: 900px;
+}
+
+#modal_overlay > .modal.login > button.important {
+ font-size: 120% !important;
+ margin-top: .5em;
+ width: 100%;
+}
+
+.uci-change-list {
+ line-height: 170%;
+ white-space: pre;
+}
+
+.uci-dialog div > del,
+.uci-dialog div > ins,
+.uci-dialog div > var {
+ margin-bottom: 2px;
+ border: 1px solid var(--border-color-high);
+ line-height: 15px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ padding: 2px;
+ position: relative;
+ background-color: var(--background-color-low);
+}
+
+.uci-dialog div > ins {
+ background-color: rgba(var(--success-color-high-rgb), .3);
+ border-color: rgba(var(--success-color-high-rgb), .6);
+}
+
+.uci-dialog div > del {
+ background-color: rgba(var(--error-color-high-rgb), .3);
+ border-color: rgba(var(--error-color-high-rgb), .6);
+}
+
+.uci-dialog var > ins {
+ background-color: rgba(var(--success-color-high-rgb), .3);
+}
+
+.uci-dialog var > del {
+ background-color: rgba(var(--error-color-high-rgb), .3);
+}
+
+.uci-change-list > var > * {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.uci-dialog del,
+.uci-dialog ins,
+.uci-dialog var {
+ text-decoration: none;
+ font-family: monospace;
+ font-style: normal;
+ color: var(--text-color-high);
+ display: block;
+}
+
+.uci-change-legend {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.uci-change-legend-label {
+ flex-basis: 150px;
+ margin: 2px;
+ display: flex;
+ align-items: center;
+}
+
+.uci-change-legend-label > ins,
+.uci-change-legend-label > del,
+.uci-change-legend-label > var {
+ margin-right: 4px;
+ width: 16px;
+ height: 16px;
+ display: flex;
+}
+
+.uci-change-legend-label > * > * {
+ flex-basis: 100%;
+ padding: 1px;
+}
+
+#applyreboot-section {
+ line-height: 300%;
+}
+
+@keyframes flash {
+ 0% { opacity: 1; }
+ 50% { opacity: .5; }
+ 100% { opacity: 1; }
+}
+
+.flash {
+ animation: flash .35s;
+}
+
+#view > div.spinning:first-child {
+ display: table;
+ margin: 15vh auto;
+}
+
+.spinning {
+ position: relative;
+ padding-left: 32px !important;
+}
+
+.spinning::before {
+ --spinner-icon: url("data:image/svg+xml,");
+
+ position: absolute;
+ top: calc(50% - 10px);
+ left: 6px;
+ width: 20px;
+ height: 20px;
+ content: " ";
+ background: var(--on-color, #000);
+ mask: var(--spinner-icon) center/cover no-repeat;
+ -webkit-mask: var(--spinner-icon) center/cover no-repeat;
+}
+
+[data-darkmode="true"] .spinning::before {
+ background: var(--on-color, #fff);
+}
+
+[data-tab-title] {
+ height: 0;
+ opacity: 0;
+ overflow: hidden;
+}
+
+[data-tab-active="true"] {
+ opacity: 1;
+ height: auto;
+ overflow: visible;
+ transition: opacity .25s ease-in;
+}
+
+.cbi-filebrowser {
+ min-width: 210px;
+ max-width: 100%;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ display: flex;
+ flex-direction: column;
+ opacity: 0;
+ height: 0;
+ overflow: hidden;
+}
+
+.cbi-filebrowser.open {
+ opacity: 1;
+ height: auto;
+ overflow: visible;
+ transition: opacity .25s ease-in;
+}
+
+.cbi-filebrowser > * {
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ padding: 0 0 .25em 0;
+ margin: .25em .25em 0px .25em;
+ white-space: nowrap;
+ border-bottom: 1px solid #ccc;
+}
+
+.cbi-filebrowser .cbi-button-positive {
+ margin-right: .25em;
+}
+
+.cbi-filebrowser > div {
+ border-bottom: none;
+}
+
+.cbi-filebrowser > ul > li {
+ display: flex;
+ flex-direction: row;
+}
+
+.cbi-filebrowser > ul > li:hover {
+ background: #f5f5f5;
+}
+
+.cbi-filebrowser > ul > li > div:first-child {
+ flex: 10;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.cbi-filebrowser > ul > li > div:last-child {
+ flex: 3;
+ text-align: right;
+}
+
+.cbi-filebrowser > ul > li > div:last-child > button {
+ padding: .125em .25em;
+ margin: 1px 0 1px .25em;
+}
+
+.cbi-filebrowser .upload {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ margin: 0 -.125em .25em -.125em;
+ padding: 0 0 .125em 0px;
+ border-bottom: 1px solid #ccc;
+}
+
+.cbi-filebrowser .upload > * {
+ margin: .125em;
+ flex: 1;
+}
+
+.cbi-filebrowser .upload > .btn {
+ flex-basis: 60px;
+}
+
+.cbi-filebrowser .upload > div {
+ flex: 10;
+ min-width: 150px;
+}
+
+.cbi-filebrowser .upload > div > input {
+ width: 100%;
+}
+
+@keyframes fade-in {
+ 0% { opacity: 0; }
+ 100% { opacity: 1; }
+}
+
+@keyframes fade-out {
+ 0% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+
+.fade-in {
+ animation: fade-in .4s ease;
+}
+
+.fade-out {
+ animation: fade-out .4s ease;
+}
+
+.assoclist .ifacebadge {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ white-space: normal;
+ text-align: center;
+}
+
+.assoclist .ifacebadge > img {
+ margin: .2em;
+}
+
+.assoclist .td:nth-of-type(3),
+.assoclist .td:nth-of-type(5) {
+ width: 25%;
+}
+
+.assoclist .td:nth-of-type(6) button {
+ word-break: normal;
+}
+
+[data-darkmode="true"] [data-page="admin-statistics-graphs"] [data-plugin] img {
+ filter: invert(100%) hue-rotate(150deg);
+}
+
+[data-page="admin-system-admin-sshkeys"] .cbi-dynlist {
+ max-width: none;
+}
+
+[data-darkmode="true"] [data-page="admin-status-channel_analysis"] #view> div > div > div > div > div[style],
+[data-darkmode="true"] [data-page="admin-status-realtime-load"] #view > div > div > div[style],
+[data-darkmode="true"] [data-page="admin-status-realtime-bandwidth"] #view > div > div > div > div[style],
+[data-darkmode="true"] [data-page="admin-status-realtime-wireless"] #view > div > div > div > div[style],
+[data-darkmode="true"] [data-page="admin-status-realtime-connections"] #view > div > div > div[style] {
+ background-color: var(--background-color-high)!important;
+}
+
+[data-darkmode="true"] [data-page="admin-status-channel_analysis"] #view> div > div > div > div > div > svg > line[style],
+[data-darkmode="true"] [data-page="admin-status-realtime-load"] #view > div > div > div > svg > line[style],
+[data-darkmode="true"] [data-page="admin-status-realtime-bandwidth"] #view > div > div > div > div > svg > line[style],
+[data-darkmode="true"] [data-page="admin-status-realtime-wireless"] #view > div > div > div > div > svg > line[style],
+[data-darkmode="true"] [data-page="admin-status-realtime-connections"] #view > div > div > div > svg > line[style] {
+ stroke: #fff!important;
+}
diff --git a/luci/luci-theme-studio/htdocs/luci-static/studio/logo.svg b/luci/luci-theme-studio/htdocs/luci-static/studio/logo.svg
new file mode 100644
index 0000000..c7a445e
--- /dev/null
+++ b/luci/luci-theme-studio/htdocs/luci-static/studio/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/luci/luci-theme-studio/htdocs/luci-static/studio/logo_48.png b/luci/luci-theme-studio/htdocs/luci-static/studio/logo_48.png
new file mode 100644
index 0000000..6fb1f75
Binary files /dev/null and b/luci/luci-theme-studio/htdocs/luci-static/studio/logo_48.png differ
diff --git a/luci/luci-theme-studio/htdocs/luci-static/studio/mobile.css b/luci/luci-theme-studio/htdocs/luci-static/studio/mobile.css
new file mode 100644
index 0000000..c6d02b2
--- /dev/null
+++ b/luci/luci-theme-studio/htdocs/luci-static/studio/mobile.css
@@ -0,0 +1,429 @@
+header h3 a, header .brand {
+ display:none !important;
+}
+
+@media screen and (max-device-width: 600px) {
+ .modal {
+ margin: 1.5em auto;
+ }
+
+ .tabs, .cbi-tabmenu {
+ background: linear-gradient(var(--background-color-high) 20%, var(--border-color-medium) 100%);
+ background-size: 1px 34px;
+ margin-bottom: 10px;
+ }
+
+ .tabs > li, .cbi-tabmenu > li {
+ height: 30px;
+ }
+
+ .tabs > li > a, .cbi-tabmenu > li > a {
+ padding: 0 8px;
+ line-height: 30px;
+ }
+
+ .table {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ }
+
+ .tr {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ border-top: 1px solid var(--border-color-medium);
+ padding: 5px 0;
+ margin: 0 -3px;
+ }
+
+ .table .th,
+ .table .td,
+ .table .tr::before {
+ flex: 1 1 50%;
+ align-self: flex-start;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ word-wrap: break-word;
+ display: inline-block;
+ border-top: none;
+ padding: 3px;
+ box-sizing: border-box;
+ }
+
+ .table .td.cbi-dropdown-open {
+ overflow: visible;
+ }
+
+ .col-1 { flex: 1 1 30px !important; -webkit-flex: 1 1 30px !important; }
+ .col-2 { flex: 2 2 60px !important; -webkit-flex: 2 2 60px !important; }
+ .col-3 { flex: 3 3 90px !important; -webkit-flex: 3 3 90px !important; }
+ .col-4 { flex: 4 4 120px !important; -webkit-flex: 4 4 120px !important; }
+ .col-5 { flex: 5 5 150px !important; -webkit-flex: 5 5 150px !important; }
+ .col-6 { flex: 6 6 180px !important; -webkit-flex: 6 6 180px !important; }
+ .col-7 { flex: 7 7 210px !important; -webkit-flex: 7 7 210px !important; }
+ .col-8 { flex: 8 8 240px !important; -webkit-flex: 8 8 240px !important; }
+ .col-9 { flex: 9 9 270px !important; -webkit-flex: 9 9 270px !important; }
+ .col-10 { flex: 10 10 300px !important; -webkit-flex: 10 10 300px !important; }
+
+ .td select {
+ word-wrap: normal;
+ }
+
+ .td[data-widget="button"],
+ .td[data-widget="fvalue"] {
+ flex: 1 1 17%;
+ text-align: left;
+ }
+
+ .td.cbi-value-field {
+ align-self: flex-start;
+ flex-basis: 100%;
+ margin-bottom: .3em;
+ }
+
+ .td.cbi-value-field .cbi-button {
+ width: 100%;
+ }
+
+ .table.cbi-section-table {
+ border: none;
+ background: none;
+ margin: 0;
+ }
+
+ .tr.table-titles,
+ .cbi-section-table-titles,
+ .cbi-section-table-descr {
+ display: none;
+ }
+
+ .cbi-section-table-row {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ margin: 0 0 .5em 0;
+ }
+
+ .cbi-section-table + .cbi-section-create {
+ padding-top: 0;
+ }
+
+ .tr[data-title]::before {
+ display: block;
+ flex: 1 1 100%;
+ background: var(--background-color-low) !important;
+ font-size: 16px;
+ border-bottom: 1px solid var(--border-color-medium);
+ }
+
+ .td[data-title]::before {
+ content: attr(data-title);
+ font-weight: bold;
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: pre-line;
+ }
+
+ .td[data-description]::after {
+ display: block;
+ }
+
+ .td[data-title] ~ .td.cbi-section-actions {
+ align-self: flex-start;
+ }
+
+ .td.cbi-section-actions {
+ overflow: initial;
+ max-width: 100%;
+ padding: 3px 2px;
+ flex-basis: 100%;
+ }
+
+ .hide-sm,
+ .hide-xs {
+ display: none !important;
+ }
+
+ .td.cbi-value-field[data-widget="CBI.DummyValue"],
+ .td.cbi-value-field[data-widget="CBI.Button"],
+ .td.cbi-value-field[data-widget="CBI.FlagValue"],
+ .td.cbi-value-field[data-widget="dvalue"],
+ .td.cbi-value-field[data-widget="button"],
+ .td.cbi-value-field[data-widget="fvalue"] {
+ flex-basis: 50%;
+ text-align: left;
+ }
+
+ .cbi-section-table .tr:hover .td,
+ .cbi-section-table .tr:hover .th,
+ .cbi-section-table .tr:hover::before {
+ background-color: transparent;
+ }
+
+ .cbi-tab-descr {
+ margin: 0 0 .5em 0;
+ }
+
+ .cbi-value {
+ padding-bottom: .5em;
+ border-bottom: 1px solid var(--border-color-high);
+ margin-bottom: .5em;
+ display: block;
+ align-items: baseline;
+ }
+
+ .cbi-value.hidden {
+ display: none;
+ }
+
+ .cbi-value .cbi-value:last-child {
+ border-bottom: none;
+ padding-bottom: inherit;
+ margin-bottom: inherit;
+ }
+
+ .cbi-value label.cbi-value-title {
+ font-weight: bold;
+ }
+
+ .cbi-value-field, .cbi-select, .cbi-dynlist, .cbi-dropdown:not(.btn):not(.cbi-button) {
+ width: 100%;
+ margin: 0;
+ }
+
+ .cbi-dynlist > .item {
+ min-height: 30px;
+ display: flex;
+ align-items: center;
+ }
+
+ input, textarea, select,
+ .cbi-dropdown > ul > li {
+ font-size: 16px !important;
+ height: auto;
+ }
+
+ .cbi-dropdown > ul > li input[type="text"] {
+ line-height: 28px;
+ }
+
+ select, input[type="text"], input[type="password"],
+ .cbi-dropdown > ul > li input[type="text"],
+ .cbi-value-field .control-group {
+ width: 100%;
+ height: 30px;
+ }
+
+ input.cbi-input-password {
+ width: calc(100% - 25px);
+ }
+
+ input[type="text"] + .cbi-button,
+ input[type="password"] + .cbi-button,
+ select + .cbi-button {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .cbi-value-field input[type="checkbox"],
+ .cbi-value-field input[type="radio"] {
+ margin: 0;
+ }
+
+ button, .btn, .cbi-button {
+ font-size: 14px !important;
+ padding: 0 8px;
+ }
+
+ .actions,
+ .cbi-page-actions {
+ border-top: none;
+ margin-top: -.5em;
+ padding: 8px;
+ }
+
+ [data-page="admin-status-overview"] .td[width="33%"] {
+ flex-basis: 33%;
+ }
+
+ header .pull-right .label {
+ white-space: normal;
+ display: inline-block;
+ text-align: center;
+ line-height: 12px;
+ margin: 1px 0;
+ }
+
+ header > .fill {
+ padding: 1px;
+ }
+
+ header > .fill > .container {
+ display: flex;
+ flex-direction: row;
+ }
+
+ header .nav {
+ flex: 3 3 80%;
+ margin: 2px 5px 2px 0;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-start;
+ }
+
+ header .nav a {
+ padding: 2px 6px;
+ }
+
+ header .pull-right {
+ flex: 0 1 20%;
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+ justify-content: space-around;
+ margin: .2em 5px .2em auto;
+ }
+
+ .menu-dropdown, .dropdown-menu {
+ top: 23px;
+ }
+
+ .cbi-optionals,
+ .cbi-section-create {
+ padding: 0 0 14px 0;
+ }
+
+ #cbi-network-switch_vlan .th,
+ #cbi-network-switch_vlan .td {
+ flex-basis: 12%;
+ }
+
+ #cbi-network-switch_vlan .td.cbi-section-actions > * {
+ width: auto;
+ display: block;
+ }
+
+ #wifi_assoclist_table .td,
+ [data-page="admin-status-processes"] .td {
+ flex-basis: 50% !important;
+ }
+
+ [data-page="admin-status-processes"] .td[data-widget="button"] {
+ flex-basis: 33% !important;
+ }
+
+ [data-page="admin-status-processes"] .td[data-name="PID"],
+ [data-page="admin-status-processes"] .td[data-name="USER"] {
+ flex-basis: 25% !important;
+ }
+
+ [data-page="admin-system-fstab"] .td[data-widget="button"]::before,
+ [data-page="admin-system-startup"] .td[data-widget="button"]::before,
+ [data-page="admin-status-processes"] .td[data-widget="button"]::before {
+ display: none;
+ }
+
+ [data-name="bridge-vlan"] > div {
+ overflow: visible !important;
+ }
+
+ [data-name="bridge-vlan"] .td[data-title] {
+ flex-basis: 33%;
+ }
+
+ [data-name="bridge-vlan"] .td[data-title]::before {
+ content: attr(data-title);
+ }
+
+ [data-page="admin-network-network"] .td[data-name="_ifacebox"] {
+ flex-basis: 40%;
+ }
+
+ [data-page="admin-network-network"] .td[data-name="_ifacestat"] {
+ flex-basis: 60%;
+ }
+}
+
+@media screen and (max-device-width: 375px) {
+ .cbi-page-actions {
+ display: flex;
+ justify-content: space-between;
+ margin: 0 -1px;
+ padding: 0;
+ }
+
+ .cbi-page-actions button {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .cbi-page-actions .cbi-button {
+ flex: 1;
+ margin: 1px !important;
+ line-height: 2em;
+ }
+
+ .cbi-page-actions .cbi-button-negative,
+ .cbi-page-actions .cbi-button-primary,
+ .cbi-page-actions .cbi-button-apply {
+ flex: 3;
+ }
+
+ .cbi-section-actions .cbi-button {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin: 1px !important;
+ }
+
+ body[data-page="admin-network-wireless"] .td[data-name="_badge"] {
+ max-width: 50px;
+ align-self: center;
+ }
+
+ body[data-page="admin-network-wireless"] .td[data-name="_badge"] .ifacebadge {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ }
+
+ body[data-page="admin-network-wireless"] .td[data-name="_stat"] {
+ flex-basis: 60%;
+ }
+
+ body[data-page="admin-network-network"] .td.cbi-section-actions::before,
+ body[data-page="admin-network-wireless"] .td.cbi-section-actions::before {
+ content: none !important;
+ }
+
+ [data-page="admin-network-network"] .td[data-name="_ifacebox"],
+ [data-page="admin-network-network"] .td[data-name="_ifacestat"] {
+ flex-basis: 100%;
+ }
+
+ .td .ifacebox {
+ width: 100%;
+ margin: 0 !important;
+ flex-direction: row;
+ }
+
+ .td .ifacebox .ifacebox-head {
+ min-width: 25%;
+ justify-content: space-around;
+ border-radius: 4px 0 0 4px;
+ }
+
+ .td .ifacebox .ifacebox-head,
+ .td .ifacebox .ifacebox-body {
+ display: flex;
+ border-bottom: none;
+ align-items: center;
+ }
+
+ .td .ifacebox .ifacebox-head > *,
+ .ifacebox .ifacebox-body > * {
+ margin: .125em;
+ }
+}
diff --git a/luci/luci-theme-studio/htdocs/luci-static/studio/studio.css b/luci/luci-theme-studio/htdocs/luci-static/studio/studio.css
new file mode 100644
index 0000000..32cd9ba
--- /dev/null
+++ b/luci/luci-theme-studio/htdocs/luci-static/studio/studio.css
@@ -0,0 +1,151 @@
+:root {
+ --studio-bg: #f3f5fb;
+ --studio-panel: #fff;
+ --studio-ink: #17213a;
+ --studio-muted: #60708b;
+ --studio-border: #dde3ee;
+ --studio-accent: #635bff;
+}
+:root[data-darkmode="true"] {
+ --studio-bg: #111522;
+ --studio-panel: #1b2133;
+ --studio-ink: #edf1ff;
+ --studio-muted: #a4b0cd;
+ --studio-border: #303952;
+ --studio-accent: #a89bff;
+}
+body {
+ background: var(--studio-bg);
+ color: var(--studio-ink);
+ font-family:
+ system-ui,
+ -apple-system,
+ "Segoe UI",
+ sans-serif;
+}
+header {
+ background: var(--studio-panel);
+ border-bottom: 1px solid var(--studio-border);
+ box-shadow: 0 4px 24px #1112;
+}
+header .brand {
+ color: var(--studio-accent);
+ font-weight: 750;
+}
+h2 {
+ font-weight: 750;
+ letter-spacing: -0.03em;
+}
+a {
+ color: var(--studio-accent);
+}
+.cbi-section,
+.cbi-map-descr,
+.studio-card {
+ border-radius: 16px;
+}
+.cbi-section,
+.studio-card {
+ background: var(--studio-panel);
+ border: 1px solid var(--studio-border);
+ padding: 20px;
+ box-shadow: 0 5px 20px #11108;
+}
+.cbi-button-positive,
+.cbi-button-apply,
+.cbi-button-save {
+ background: var(--studio-accent);
+ border-radius: 8px;
+ color: #fff;
+}
+.studio-hero {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+ margin: 16px 0 24px;
+}
+.studio-hero p,
+.studio-muted {
+ color: var(--studio-muted);
+}
+.studio-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 16px;
+ margin-bottom: 20px;
+}
+.studio-card h3 {
+ margin: 0 0 12px;
+ font-size: 16px;
+}
+.studio-value {
+ font-size: 30px;
+ font-weight: 750;
+}
+.studio-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+.studio-links a {
+ display: inline-block;
+ padding: 10px 14px;
+ border: 1px solid var(--studio-border);
+ border-radius: 10px;
+ background: var(--studio-panel);
+}
+.studio-meter {
+ height: 7px;
+ background: var(--studio-border);
+ border-radius: 9px;
+ overflow: hidden;
+ margin-top: 12px;
+}
+.studio-meter span {
+ display: block;
+ height: 100%;
+ background: var(--studio-accent);
+}
+.studio-status {
+ display: inline-block;
+ border-radius: 20px;
+ padding: 4px 10px;
+ background: #10b98122;
+ color: var(--studio-ink);
+}
+.studio-warn {
+ background: #f59e0b33;
+}
+textarea.studio-editor {
+ width: 100%;
+ min-height: 340px;
+ font-family: monospace;
+}
+.studio-actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin: 18px 0;
+}
+.studio-card pre {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ max-height: 450px;
+ overflow: auto;
+}
+button {
+ cursor: pointer;
+}
+@media (max-width: 600px) {
+ .studio-hero {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .studio-grid {
+ grid-template-columns: 1fr;
+ }
+ .studio-card {
+ padding: 15px;
+ }
+}
diff --git a/luci/luci-theme-studio/htdocs/luci-static/studio/studio.js b/luci/luci-theme-studio/htdocs/luci-static/studio/studio.js
new file mode 100644
index 0000000..82be104
--- /dev/null
+++ b/luci/luci-theme-studio/htdocs/luci-static/studio/studio.js
@@ -0,0 +1,55 @@
+/* OpenWrt Studio theme controls. No telemetry or external resources. */
+(function () {
+ "use strict";
+ var key = "openwrt-studio-palette",
+ root = document.documentElement,
+ choice;
+ try {
+ choice = localStorage.getItem(key);
+ } catch (e) {}
+ function apply() {
+ root.setAttribute(
+ "data-darkmode",
+ choice === "dark"
+ ? "true"
+ : choice === "light"
+ ? "false"
+ : String(matchMedia("(prefers-color-scheme: dark)").matches),
+ );
+ }
+ apply();
+ matchMedia("(prefers-color-scheme: dark)").addEventListener("change", apply);
+ document.addEventListener("DOMContentLoaded", function () {
+ var header = document.querySelector("header");
+ if (!header) return;
+ var button = document.createElement("button");
+ button.className = "cbi-button";
+ button.type = "button";
+ button.textContent = "◐";
+ button.title = "Сменить светлую / тёмную тему";
+ button.setAttribute("aria-label", button.title);
+ button.addEventListener("click", function () {
+ choice = root.getAttribute("data-darkmode") === "true" ? "light" : "dark";
+ try {
+ localStorage.setItem(key, choice);
+ } catch (e) {}
+ apply();
+ });
+ header.appendChild(button);
+ });
+ document.addEventListener("keydown", function (event) {
+ if (!event.ctrlKey || event.altKey || event.metaKey) return;
+ var routes = {
+ 1: "admin/studio/overview",
+ 2: "admin/studio/overview",
+ 3: "admin/studio/diagnostics",
+ ",": "admin/studio/tools",
+ },
+ route = routes[event.key];
+ if (route && window.L) {
+ event.preventDefault();
+ location.href =
+ L.url(route) + (event.key === "2" ? "#studio-clients" : "");
+ }
+ });
+})();
diff --git a/luci/luci-theme-studio/root/etc/uci-defaults/90-luci-theme-studio b/luci/luci-theme-studio/root/etc/uci-defaults/90-luci-theme-studio
new file mode 100755
index 0000000..6831887
--- /dev/null
+++ b/luci/luci-theme-studio/root/etc/uci-defaults/90-luci-theme-studio
@@ -0,0 +1,4 @@
+#!/bin/sh
+uci -q set luci.themes.OpenWrtStudio='/luci-static/studio'
+uci commit luci
+exit 0
diff --git a/luci/luci-theme-studio/ucode/template/themes/studio/footer.ut b/luci/luci-theme-studio/ucode/template/themes/studio/footer.ut
new file mode 100644
index 0000000..d51079b
--- /dev/null
+++ b/luci/luci-theme-studio/ucode/template/themes/studio/footer.ut
@@ -0,0 +1,20 @@
+ {% if (!blank_page): %}
+
+
+
+ {% endif %}
+