From 85f7b195f8a9f79f1c5d48273e557b2042a0cb6e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 7 Jun 2026 00:43:45 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Sync=202026-06-07=2000:43:45?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- luci-app-daede/Makefile | 110 ++ .../resources/view/daede/backend.js | 137 ++ .../resources/view/daede/config.js | 1141 +++++++++++++++++ .../luci-static/resources/view/daede/log.js | 305 +++++ .../resources/view/daede/updates.js | 309 +++++ luci-app-daede/po/templates/daede.pot | 362 ++++++ luci-app-daede/po/zh-cn/daede.po | 653 ++++++++++ luci-app-daede/po/zh_Hans/daede.po | 446 +++++++ luci-app-daede/root/etc/config/daede | 2 + .../etc/uci-defaults/90-luci-app-daede-init | 153 +++ .../share/luci-app-daede/gen-dae-config.sh | 368 ++++++ .../root/usr/share/luci-app-daede/pkg-info.sh | 32 + .../usr/share/luci-app-daede/proxy-check.sh | 27 + .../usr/share/luci-app-daede/update-geo.sh | 69 + .../usr/share/luci-app-daede/update-pkg.sh | 68 + .../usr/share/luci/menu.d/luci-app-daede.json | 36 + .../usr/share/rpcd/acl.d/luci-app-daede.json | 74 ++ natflow/Makefile | 4 +- 18 files changed, 4294 insertions(+), 2 deletions(-) create mode 100644 luci-app-daede/Makefile create mode 100644 luci-app-daede/htdocs/luci-static/resources/view/daede/backend.js create mode 100644 luci-app-daede/htdocs/luci-static/resources/view/daede/config.js create mode 100644 luci-app-daede/htdocs/luci-static/resources/view/daede/log.js create mode 100644 luci-app-daede/htdocs/luci-static/resources/view/daede/updates.js create mode 100644 luci-app-daede/po/templates/daede.pot create mode 100644 luci-app-daede/po/zh-cn/daede.po create mode 100644 luci-app-daede/po/zh_Hans/daede.po create mode 100644 luci-app-daede/root/etc/config/daede create mode 100644 luci-app-daede/root/etc/uci-defaults/90-luci-app-daede-init create mode 100755 luci-app-daede/root/usr/share/luci-app-daede/gen-dae-config.sh create mode 100755 luci-app-daede/root/usr/share/luci-app-daede/pkg-info.sh create mode 100755 luci-app-daede/root/usr/share/luci-app-daede/proxy-check.sh create mode 100755 luci-app-daede/root/usr/share/luci-app-daede/update-geo.sh create mode 100755 luci-app-daede/root/usr/share/luci-app-daede/update-pkg.sh create mode 100644 luci-app-daede/root/usr/share/luci/menu.d/luci-app-daede.json create mode 100644 luci-app-daede/root/usr/share/rpcd/acl.d/luci-app-daede.json diff --git a/luci-app-daede/Makefile b/luci-app-daede/Makefile new file mode 100644 index 000000000..30874b9ae --- /dev/null +++ b/luci-app-daede/Makefile @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Copyright (C) 2023 ImmortalWrt.org + +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-daede +PKG_VERSION:=1.5 +PKG_RELEASE:=1 +PKG_MAINTAINER:=kenzok8 +PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) + +include $(INCLUDE_DIR)/package.mk + +define Package/$(PKG_NAME)/config + choice + prompt "Backend dependency" + default PACKAGE_$(PKG_NAME)_daed + help + Select which backend package luci-app-daede should pull in by default. + + config PACKAGE_$(PKG_NAME)_daed + bool "daed backend" + + config PACKAGE_$(PKG_NAME)_dae + bool "dae backend" + + endchoice +endef + +define Package/$(PKG_NAME) + SECTION:=luci + CATEGORY:=LuCI + SUBMENU:=3. Applications + TITLE:=LuCI app for daede dashboard + DEPENDS:=+luci-base +PACKAGE_$(PKG_NAME)_dae:dae +PACKAGE_$(PKG_NAME)_daed:daed + PKGARCH:=all + MAINTAINER:=kenzok8 +endef + +define Package/$(PKG_NAME)/description + LuCI web interface for managing dae or daed backends. +endef + +define Build/Prepare + mkdir -p $(PKG_BUILD_DIR) + po2lmo ./po/zh-cn/daede.po $(PKG_BUILD_DIR)/daede.zh-cn.lmo +endef + +define Build/Configure +endef + +define Build/Compile +endef + +define Package/$(PKG_NAME)/conffiles +/etc/config/daede +endef + +define Package/$(PKG_NAME)/install + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_DATA) ./root/etc/config/daede $(1)/etc/config/daede + + $(INSTALL_DIR) $(1)/etc/uci-defaults + $(INSTALL_BIN) ./root/etc/uci-defaults/90-luci-app-daede-init $(1)/etc/uci-defaults/90-luci-app-daede-init + + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-daede.json $(1)/usr/share/luci/menu.d/luci-app-daede.json + + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-daede.json $(1)/usr/share/rpcd/acl.d/luci-app-daede.json + + $(INSTALL_DIR) $(1)/usr/share/luci-app-daede + $(INSTALL_BIN) ./root/usr/share/luci-app-daede/pkg-info.sh $(1)/usr/share/luci-app-daede/pkg-info.sh + $(INSTALL_BIN) ./root/usr/share/luci-app-daede/update-geo.sh $(1)/usr/share/luci-app-daede/update-geo.sh + $(INSTALL_BIN) ./root/usr/share/luci-app-daede/update-pkg.sh $(1)/usr/share/luci-app-daede/update-pkg.sh + $(INSTALL_BIN) ./root/usr/share/luci-app-daede/gen-dae-config.sh $(1)/usr/share/luci-app-daede/gen-dae-config.sh + $(INSTALL_BIN) ./root/usr/share/luci-app-daede/proxy-check.sh $(1)/usr/share/luci-app-daede/proxy-check.sh + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/daede + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/daede/*.js $(1)/www/luci-static/resources/view/daede/ + + $(INSTALL_DIR) $(1)/usr/lib/lua/luci/i18n + $(INSTALL_DATA) $(PKG_BUILD_DIR)/daede.zh-cn.lmo $(1)/usr/lib/lua/luci/i18n/daede.zh-cn.lmo +endef + +define Package/$(PKG_NAME)/postinst +#!/bin/sh +if [ -z "$${IPKG_INSTROOT}" ]; then + if [ -x /etc/uci-defaults/90-luci-app-daede-init ]; then + ( . /etc/uci-defaults/90-luci-app-daede-init ) + rm -f /etc/uci-defaults/90-luci-app-daede-init + fi + rm -f /usr/lib/lua/luci/i18n/daed.zh-cn.lmo + rm -rf /tmp/luci-indexcache /tmp/luci-indexcache.* /tmp/luci-modulecache + /etc/init.d/rpcd restart >/dev/null 2>&1 || true + /etc/init.d/uhttpd restart >/dev/null 2>&1 || true +fi +exit 0 +endef + +define Package/$(PKG_NAME)/postrm +#!/bin/sh +if [ -z "$${IPKG_INSTROOT}" ]; then + rm -rf /tmp/luci-indexcache /tmp/luci-indexcache.* /tmp/luci-modulecache +fi +exit 0 +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/backend.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/backend.js new file mode 100644 index 000000000..4f42339ec --- /dev/null +++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/backend.js @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 + +'use strict'; +'require baseclass'; +'require fs'; +'require rpc'; +'require uci'; + +const callServiceList = rpc.declare({ + object: 'service', + method: 'list', + params: ['name'], + expect: { '': {} } +}); + +const BACKENDS = { + daed: { + name: 'daed', + label: 'daed', + uci: 'daed', + initd: '/etc/init.d/daed', + log: '/var/log/daed/daed.log', + pkg: 'daed', + hasWebUI: true, + useNetns: true, + defaultListen: '0.0.0.0:2023' + }, + dae: { + name: 'dae', + label: 'dae', + uci: 'dae', + initd: '/etc/init.d/dae', + log: '/var/log/dae/dae.log', + pkg: 'dae', + config: '/etc/dae/config.dae', + example: '/etc/dae/example.dae', + hasWebUI: false, + useNetns: false + } +}; + +function execOk(cmd, args) { + return L.resolveDefault(fs.exec(cmd, args || []), { code: 1 }).then(function(res) { + return res && res.code === 0; + }); +} + +function exists(path) { + return L.resolveDefault(fs.stat(path), null).then(function(st) { + return !!st; + }); +} + +function serviceStatus(name) { + return L.resolveDefault(callServiceList(name), {}).then(function(svc) { + let pid = 0, running = false; + + try { + const inst = svc[name].instances[name]; + running = !!inst.running; + pid = inst.pid || 0; + } catch (e) {} + + return { running: running, pid: pid }; + }); +} + +function detectInstalledBackends() { + return Promise.all([ + exists(BACKENDS.dae.initd), + exists(BACKENDS.daed.initd) + ]).then(function(r) { + return { dae: r[0], daed: r[1] }; + }); +} + +function detectRunning() { + return Promise.all([ + execOk('/usr/bin/pgrep', ['-x', 'dae']), + execOk('/usr/bin/pgrep', ['-x', 'daed']) + ]).then(function(r) { + return { dae: r[0], daed: r[1] }; + }); +} + +function loadActiveBackend() { + return L.resolveDefault(uci.load('daede'), null).then(function() { + const active = uci.get('daede', 'config', 'active_backend'); + return BACKENDS[active] ? active : ''; + }); +} + +function setActiveBackend(name) { + if (!BACKENDS[name]) + return Promise.reject(new Error('invalid backend: ' + name)); + + return exists('/etc/config/daede').then(function(ok) { + if (!ok) + return fs.write('/etc/config/daede', ''); + }).then(function() { return fs.exec('/sbin/uci', ['set', 'daede.config=daede']); }) + .then(function() { return fs.exec('/sbin/uci', ['set', 'daede.config.active_backend=' + name]); }) + .then(function() { return fs.exec('/sbin/uci', ['commit', 'daede']); }); +} + +function detectBackend() { + return Promise.all([ + detectInstalledBackends(), + detectRunning(), + loadActiveBackend() + ]).then(function(r) { + const installed = r[0]; + const running = r[1]; + const preferred = r[2]; + let name = preferred; + + if (!name || !installed[name]) + name = installed.daed ? 'daed' : (installed.dae ? 'dae' : 'daed'); + + return { + name: name, + backend: BACKENDS[name], + installed: installed, + running: running, + preferred: preferred, + bothInstalled: !!(installed.dae && installed.daed) + }; + }); +} + +return baseclass.extend({ + BACKENDS: BACKENDS, + detectInstalledBackends: detectInstalledBackends, + detectRunning: detectRunning, + detectBackend: detectBackend, + setActiveBackend: setActiveBackend, + serviceStatus: serviceStatus +}); diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/config.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/config.js new file mode 100644 index 000000000..a958681cd --- /dev/null +++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/config.js @@ -0,0 +1,1141 @@ +// SPDX-License-Identifier: Apache-2.0 + +'use strict'; +'require form'; +'require fs'; +'require poll'; +'require uci'; +'require ui'; +'require view'; +'require view.daede.backend as backend'; + +const CSS = [ + '.dd-wrap{padding:4px 0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}', + '.dd-card{border:1px solid rgba(0,0,0,.06);border-radius:10px;padding:10px 14px;margin-bottom:10px;box-shadow:0 2px 8px rgba(0,0,0,.03);background:rgba(255,255,255,.02)}', + '.dd-card-title{font-size:11px;font-weight:600;opacity:.55;margin:0 0 8px;letter-spacing:.3px;text-transform:uppercase}', + '.dd-status-row{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:0}', + '.dd-status-row .dd-grow{flex:1 1 auto}', + '.dd-badge{display:inline-flex;align-items:center;gap:5px;padding:2px 10px;border-radius:999px;font-size:10.5px;font-weight:700;letter-spacing:.3px;border:1px solid transparent;line-height:1.3}', + '.dd-badge-run{color:#3da66a;border-color:rgba(61,166,106,.5)}', + '.dd-badge-stop{color:#d96d6d;border-color:rgba(217,109,109,.55)}', + '.dd-badge-dot{width:6px;height:6px;border-radius:50%;background:currentColor;display:inline-block}', + '.dd-meta{font-size:11.5px;opacity:.7;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace}', + '.dd-meta-label{opacity:.55;margin-right:4px}', + '.dd-actions{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 0}', + '.dd-actions .cbi-button{font-size:11.5px;padding:4px 12px;border-radius:5px}', + '.dd-actions a.cbi-button{display:inline-flex;align-items:center;gap:4px}', + '.dd-wrap .cbi-button{font-size:11px !important;padding:4px 12px !important;border-radius:5px !important;border:1px solid rgba(128,128,128,.35) !important;background:transparent !important;color:inherit !important;box-shadow:none !important;cursor:pointer;white-space:nowrap}', + '.dd-wrap .cbi-button:hover{background:rgba(128,128,128,.12) !important}', + '.dd-wrap .cbi-button:disabled{opacity:.45;cursor:not-allowed}', + '.dd-wrap .cbi-button-action,.dd-wrap .cbi-button-positive,.dd-wrap .cbi-button-add,.dd-wrap .cbi-button-edit{border-color:#4aa065 !important;color:#4aa065 !important}', + '.dd-wrap .cbi-button-remove{border-color:#d96d6d !important;color:#d96d6d !important}', + '.dd-wrap .cbi-button-remove:hover{background:rgba(217,109,109,.12) !important}', + '.dd-switch{position:relative;width:42px;height:22px;border:0;border-radius:999px;background:rgba(128,128,128,.28);padding:0;cursor:pointer;transition:background .18s ease,opacity .18s ease;flex-shrink:0}', + '.dd-switch .dd-switch-knob{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:rgba(255,255,255,.96);box-shadow:0 1px 4px rgba(0,0,0,.2);transition:transform .18s ease}', + '.dd-switch.is-on{background:rgba(74,160,101,.65)}', + '.dd-switch.is-on .dd-switch-knob{transform:translateX(20px)}', + '.dd-switch:disabled{opacity:.45;cursor:not-allowed}', + '.dd-switch-label{font-size:10.5px;font-weight:600;opacity:.62;letter-spacing:.3px}', + '.dd-switch-wrap{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}', + '.dd-backend-card{padding:10px 14px}', + '.dd-backend-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}', + '.dd-backend-label{min-width:100px;font-size:12px;font-weight:600;opacity:.72}', + '.dd-backend-segment{display:inline-flex;align-items:center;gap:2px;padding:2px;border-radius:7px;background:rgba(128,128,128,.10)}', + '.dd-backend-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-width:78px;height:24px;padding:0 10px;border:0;border-radius:5px;background:transparent;color:inherit;font-size:11px;font-weight:500;opacity:.65;cursor:pointer;transition:background .18s ease,color .18s ease,opacity .18s ease}', + '.dd-backend-btn:hover{background:rgba(128,128,128,.10)}', + '.dd-backend-btn.is-active{background:linear-gradient(#3886a1,#2f7288);color:#fff;opacity:1;font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.12)}', + '.dd-backend-btn:disabled{opacity:.55;cursor:not-allowed}', + '.dd-backend-state{font-size:10.5px;font-weight:500;opacity:.70;margin-left:2px}', + '.dd-backend-btn.is-active .dd-backend-state{opacity:.85}', + '.dd-backend-help{margin:6px 0 0 110px;font-size:11.5px;line-height:1.45;opacity:.66}', + '@media (max-width:640px){.dd-backend-label{min-width:0;width:100%}.dd-backend-segment{width:100%}.dd-backend-btn{flex:1;min-width:0}.dd-backend-help{margin-left:0}}', + /* daed/dae settings card —— LuCI form.Map 字体/边框对齐 dd 卡片体系 */ + '.dd-settings-card{padding:10px 14px}', + '.dd-settings-card>h2,.dd-settings-card .cbi-map>h2,.dd-settings-card .cbi-section>h3{display:none}', + '.dd-settings-card .cbi-map>.cbi-map-descr,.dd-settings-card .cbi-section-descr{font-size:11.5px;opacity:.62;margin:0 0 8px;line-height:1.45}', + '.dd-settings-descr{font-size:11.5px;opacity:.62;margin:0 0 8px;line-height:1.45}', + '.dd-settings-card .cbi-section{margin:0;padding:0;background:transparent;border:0;box-shadow:none}', + '.dd-settings-card .cbi-value{padding:6px 0;border:0;min-height:0}', + '.dd-settings-card .cbi-value-title{font-size:12.5px !important;font-weight:500;opacity:.85;padding:6px 12px 6px 0;min-width:140px}', + '.dd-settings-card .cbi-value-field input,.dd-settings-card .cbi-value-field select,.dd-settings-card .cbi-value-field textarea{font-size:12.5px !important;padding:5px 8px;border-radius:5px;border:1px solid rgba(128,128,128,.28);background:transparent;color:inherit}', + '.dd-settings-card .cbi-value-field input:focus,.dd-settings-card .cbi-value-field select:focus,.dd-settings-card .cbi-value-field textarea:focus{border-color:rgba(56,134,161,.7);outline:0;box-shadow:0 0 0 2px rgba(56,134,161,.15)}', + '.dd-settings-card .cbi-value-description,.dd-settings-card .cbi-value-helptext{font-size:11.5px !important;opacity:.6;line-height:1.45;padding-top:3px}', + '.dd-settings-card .cbi-section-remove{background:transparent !important;border:0 !important;box-shadow:none !important}', + '.dd-settings-card .cbi-section-remove .cbi-button{border-color:#d96d6d !important;color:#d96d6d !important}', + '.dd-settings-card input[type="checkbox"]{box-shadow:none !important;background-repeat:no-repeat !important;background-position:center !important;background-size:62% !important}', + '.dd-settings-card input[type="checkbox"]:before{content:none !important;display:none !important;box-shadow:none !important}', + /* line the Subscriptions and Nodes grid tables up column-for-column. fixed + layout + explicit per-column percentages make the widths authoritative + across themes (Argon's auto layout otherwise sizes the empty Nodes table + by content, and even ignores px cell widths, so they drift apart) */ + '.dd-settings-card .cbi-section-table{table-layout:fixed !important;width:100%}', + '.dd-settings-card .cbi-section-table-cell:nth-child(1){width:14% !important}', + '.dd-settings-card .cbi-section-table-cell:nth-child(2){width:50% !important;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', + '.dd-settings-card .cbi-section-table-cell:nth-child(3){width:12% !important}', + '.dd-settings-card .cbi-section-table-cell:nth-child(4){width:24% !important;min-width:210px}', + /* proxy-test result coloring */ + '.dd-meta.dd-ok{color:#3da66a;font-weight:600}', + '.dd-meta.dd-err{color:#d96d6d;font-weight:600}', + /* 手风琴折叠组 —— 视觉与 clashoo cl-component-adv 一致 */ + '.dd-adv{margin-top:10px}', + '.dd-adv-bar{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;cursor:pointer;user-select:none;font-size:12px;font-weight:600;background:rgba(128,128,128,.06);border:1px solid rgba(128,128,128,.16);border-radius:7px;color:inherit;opacity:.55}', + '.dd-adv-bar:hover{background:rgba(56,134,161,.08);opacity:1}', + '.dd-adv-chevron{font-size:14px;font-weight:700;opacity:.55;transition:transform .2s}', + '.dd-adv:not(.dd-closed) .dd-adv-chevron{transform:rotate(90deg)}', + '.dd-adv-body{margin-top:8px;padding:2px 4px 4px}', + '.dd-adv.dd-closed .dd-adv-body{display:none}', + '.dd-editor{width:100%;min-height:460px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace;font-size:12px;line-height:1.5;box-sizing:border-box;resize:vertical;border-radius:6px 6px 0 0}', + '.dd-editor-footer{display:flex;flex-wrap:wrap;align-items:center;gap:14px;padding:5px 10px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:10.5px;background:rgba(128,128,128,.06);border:1px solid rgba(0,0,0,.06);border-top:0;border-radius:0 0 6px 6px;color:inherit;opacity:.78}', + '.dd-editor-footer .dd-fb-item{display:inline-flex;align-items:center;gap:5px}', + '.dd-editor-footer .dd-fb-key{opacity:.55}', + '.dd-editor-footer .dd-fb-warn{color:#b07d00;font-weight:600}', + '.dd-editor-footer .dd-fb-ok{color:#3da66a;font-weight:600}', + 'body.dark .dd-editor-footer,html[data-theme="dark"] .dd-editor-footer,html[data-bs-theme="dark"] .dd-editor-footer{border-color:rgba(255,255,255,.1);background:rgba(255,255,255,.04)}', + 'body.dark .dd-editor-footer .dd-fb-warn,html[data-theme="dark"] .dd-editor-footer .dd-fb-warn,html[data-bs-theme="dark"] .dd-editor-footer .dd-fb-warn{color:#e0b34a}', + '.dd-insert-select{font-size:11.5px;padding:4px 8px;border-radius:5px;border:1px solid rgba(128,128,128,.35);background:transparent;color:inherit;cursor:pointer}', + '.dd-insert-select:hover{border-color:rgba(56,134,161,.55)}', + '.dd-editor-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:10px}', + '.dd-editor-status{margin-left:auto;font-size:11.5px;opacity:0;transition:opacity .25s ease;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}', + '.dd-editor-status.show{opacity:1}', + '.dd-editor-status.ok{color:#3da66a}', + '.dd-editor-status.err{color:#d96d6d}', + '.dd-editor-hint{font-size:11.5px;opacity:.62;line-height:1.5;margin:0 0 8px;padding:7px 10px;border-radius:6px;background:rgba(56,134,161,.06)}', + '.dd-editor-hint b{font-weight:600;opacity:.85}', + '.dd-sub-card{margin:0 0 10px;padding:10px 14px;border:0;border-radius:8px;background:transparent}', + '.dd-sub-card-title{font-size:11px;font-weight:600;opacity:.55;margin:0 0 8px;letter-spacing:.3px;text-transform:uppercase}', + '.dd-sub-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}', + '.dd-sub-row input{flex:1;min-width:200px;font-size:12px;padding:6px 10px;border-radius:5px;border:1px solid rgba(128,128,128,.28);background:transparent;color:var(--text-color,inherit)}', + '.dd-sub-row input:focus{border-color:rgba(74,160,101,.55);outline:0;box-shadow:0 0 0 2px rgba(74,160,101,.12)}.dd-sub-row input::placeholder{opacity:.45;color:rgba(92,102,120,.8)}', + '.dd-sub-apply{font-size:11px;padding:5px 14px;border-radius:5px;border:1px solid #4aa065;color:#4aa065;background:transparent;cursor:pointer;white-space:nowrap;font-weight:600}', + '.dd-sub-apply:hover{background:rgba(74,160,101,.12)}', + '.dd-sub-apply:disabled{opacity:.45;cursor:not-allowed}', + '.dd-sub-status{font-size:11px;margin-left:8px;opacity:0;transition:opacity .2s;white-space:nowrap}', + '.dd-sub-status.show{opacity:1}', + '.dd-ph-warn{display:none;font-size:12px;line-height:1.6;margin:0 0 8px;padding:10px 12px;border-radius:6px;background:rgba(217,158,0,.08);color:inherit}', + '.dd-ph-warn.show{display:block}', + '.dd-ph-warn-title{font-weight:600;margin-bottom:6px;color:#b07d00;font-size:12.5px}', + 'body.dark .dd-ph-warn-title,html[data-theme="dark"] .dd-ph-warn-title,html[data-bs-theme="dark"] .dd-ph-warn-title{color:#e0b34a}', + '.dd-ph-howto{margin:4px 0 8px;opacity:.85}', + '.dd-ph-howto code{padding:1px 5px;border-radius:3px;background:rgba(128,128,128,.18);font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}', + '.dd-ph-list{margin:6px 0 0;padding:6px 0 0;list-style:none;border-top:1px dashed rgba(217,158,0,.3)}', + '.dd-ph-list-label{font-size:11px;opacity:.6;margin:0 0 4px}', + '.dd-ph-list li{padding:2px 0;display:flex;gap:8px;align-items:baseline;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:11px}', + '.dd-ph-list .dd-ph-ln{display:inline-block;min-width:38px;text-align:center;padding:1px 4px;border-radius:3px;background:rgba(217,158,0,.18);color:#8a6300;opacity:.95;cursor:pointer;font-weight:600;text-decoration:none}', + '.dd-ph-list .dd-ph-ln:hover{background:rgba(217,158,0,.32)}', + 'body.dark .dd-ph-list .dd-ph-ln,html[data-theme="dark"] .dd-ph-list .dd-ph-ln,html[data-bs-theme="dark"] .dd-ph-list .dd-ph-ln{color:#f0c763;background:rgba(217,158,0,.22)}', + '.dd-ph-list .dd-ph-txt{opacity:.7;word-break:break-all}', + 'body.dark .dd-card,html[data-theme="dark"] .dd-card,html[data-bs-theme="dark"] .dd-card{border-color:rgba(255,255,255,.08);background:rgba(255,255,255,.02)}', + 'body.dark .dd-adv-bar,html[data-theme="dark"] .dd-adv-bar,html[data-bs-theme="dark"] .dd-adv-bar{background:rgba(255,255,255,.04);border-color:rgba(255,255,255,.10)}', + 'body.dark .dd-settings-card .cbi-value-field input,body.dark .dd-settings-card .cbi-value-field select,body.dark .dd-settings-card .cbi-value-field textarea,html[data-theme="dark"] .dd-settings-card .cbi-value-field input,html[data-theme="dark"] .dd-settings-card .cbi-value-field select,html[data-theme="dark"] .dd-settings-card .cbi-value-field textarea,html[data-bs-theme="dark"] .dd-settings-card .cbi-value-field input,html[data-bs-theme="dark"] .dd-settings-card .cbi-value-field select,html[data-bs-theme="dark"] .dd-settings-card .cbi-value-field textarea{border-color:rgba(255,255,255,.18)}' +].join(''); + +function notifyAction() {} + +function execInit(be, action) { + return fs.exec(be.initd, [action]).then(function(res) { + notifyAction(action, res); + }).catch(function() {}); +} + +function rejectIfOtherRunning(be, running) { + const other = be.name === 'dae' ? 'daed' : 'dae'; + if (!running || !running[other]) + return Promise.resolve(); + + return Promise.reject(new Error(_('%s is already running. Stop %s before starting %s because both backends share the same eBPF/cgroup attachment.').format(other, other, be.name))); +} + +function toggleService(be, turnOn, running) { + const enabled = turnOn ? '1' : '0'; + const action = turnOn ? 'start' : 'stop'; + + return rejectIfOtherRunning(be, running) + .then(function() { return fs.exec('/sbin/uci', ['set', be.uci + '.config.enabled=' + enabled]); }) + .then(function() { return fs.exec('/sbin/uci', ['commit', be.uci]); }) + .then(function() { + if (turnOn) + return fs.exec(be.initd, ['enable']); + return fs.exec(be.initd, ['disable']); + }) + .then(function() { + if (turnOn && be.useNetns) + return fs.exec('/sbin/ip', ['netns', 'del', 'daens']).catch(function() {}); + }) + .then(function() { return fs.exec(be.initd, [action]); }) + .then(function(res) { notifyAction(action, res); }) + .catch(function(e) { + ui.addNotification(null, E('p', _('Toggle failed: %s').format(e.message || e)), 'danger'); + }); +} + +function renderBackendSwitcher(ctx) { + if (!ctx.installed.dae && !ctx.installed.daed) + return null; + + const wrap = E('div', { 'class': 'dd-card dd-backend-card' }, [ + E('h4', { 'class': 'dd-card-title' }, _('Backend')), + E('div', { 'class': 'dd-backend-row' }, [ + E('span', { 'class': 'dd-backend-label' }, _('Active backend')), + E('div', { 'class': 'dd-backend-segment' }) + ]) + ]); + const segment = wrap.querySelector('.dd-backend-segment'); + const hint = E('div', { 'class': 'dd-backend-help' }, _('Switching backend stops the current service first. Click start when you want the new backend to run.')); + let busy = false; + + const showHint = function(msg) { + hint.textContent = msg; + }; + + const stopIfRunning = function(running) { + const stops = []; + + ['dae', 'daed'].forEach(function(name) { + if (running && running[name]) + stops.push(fs.exec(backend.BACKENDS[name].initd, ['stop']).catch(function() {})); + }); + + if (stops.length) + showHint(_('Stopping current backend…')); + + return Promise.all(stops); + }; + + ['daed', 'dae'].forEach(function(name) { + const active = ctx.name === name; + const installed = !!ctx.installed[name]; + const btn = E('button', { + 'class': 'dd-backend-btn' + (active ? ' is-active' : ''), + 'type': 'button', + 'disabled': null, + 'title': installed ? _('Switch to %s').format(name) : _('%s is not installed').format(name) + }, [ + E('span', {}, name), + E('span', { 'class': 'dd-backend-state' }, active ? _('Active') : (installed ? '' : _('Not installed'))) + ]); + btn.addEventListener('click', function(ev) { + ev.preventDefault(); + if (busy || active) + return; + if (!installed) { + showHint(_('%s is not installed. Install it first, then switch backend.').format(name)); + return; + } + + busy = true; + Array.prototype.forEach.call(segment.querySelectorAll('button'), function(b) { b.disabled = true; }); + showHint(_('Switching to %s…').format(name)); + + backend.detectRunning() + .then(stopIfRunning) + .then(function() { return backend.setActiveBackend(name); }) + .then(function() { + showHint(_('Switched to %s. Click start when you want it to run.').format(name)); + setTimeout(function() { window.location.reload(); }, 650); + }) + .catch(function(e) { + busy = false; + Array.prototype.forEach.call(segment.querySelectorAll('button'), function(b) { b.disabled = false; }); + showHint(_('Switch failed.')); + }); + }); + segment.appendChild(btn); + }); + + wrap.appendChild(hint); + return wrap; +} + +function renderStatusCard(ctx, listenAddr) { + const be = ctx.backend; + const body = E('div', { 'id': 'dd-status-body' }, E('em', {}, _('Collecting data…'))); + const card = E('div', { 'class': 'dd-card' }, [ + E('h4', { 'class': 'dd-card-title' }, _('Service Status')), + body + ]); + + const render = function(state, running) { + while (body.firstChild) body.removeChild(body.firstChild); + + const badge = state.running + ? E('span', { 'class': 'dd-badge dd-badge-run' }, [ E('span', { 'class': 'dd-badge-dot' }), _('RUNNING') ]) + : E('span', { 'class': 'dd-badge dd-badge-stop' }, [ E('span', { 'class': 'dd-badge-dot' }), _('STOPPED') ]); + + const meta = [ + E('span', { 'class': 'dd-meta' }, [ E('span', { 'class': 'dd-meta-label' }, _('Backend')), be.name ]) + ]; + + if (state.running && state.pid) + meta.push(E('span', { 'class': 'dd-meta' }, [ E('span', { 'class': 'dd-meta-label' }, 'PID'), state.pid ])); + if (be.hasWebUI) + meta.push(E('span', { 'class': 'dd-meta' }, [ E('span', { 'class': 'dd-meta-label' }, _('Listen')), listenAddr || be.defaultListen ])); + + const sw = E('button', { 'class': 'dd-switch' + (state.running ? ' is-on' : ''), 'type': 'button', 'aria-label': _('Toggle service') }, [ + E('span', { 'class': 'dd-switch-knob' }) + ]); + sw.addEventListener('click', function(ev) { + ev.preventDefault(); + if (sw.disabled) return; + sw.disabled = true; + const turnOn = !state.running; + /* instant optimistic feedback — the start/stop chain (esp. dae's eBPF + load) takes a few seconds; flip the switch and show a pending label + right away instead of looking frozen */ + sw.classList.toggle('is-on', turnOn); + const lbl = sw.parentNode && sw.parentNode.querySelector('.dd-switch-label'); + if (lbl) lbl.textContent = '…'; + toggleService(be, turnOn, running) + /* refresh as soon as the command returns, not on the next poll + tick — this is what made feedback feel laggy */ + .then(function() { return refresh(); }) + .finally(function() { sw.disabled = false; }); + }); + + const row = E('div', { 'class': 'dd-status-row' }, [ badge ].concat(meta).concat([ + E('span', { 'class': 'dd-grow' }), + E('span', { 'class': 'dd-switch-wrap' }, [ + E('span', { 'class': 'dd-switch-label' }, state.running ? 'ON' : 'OFF'), + sw + ]) + ])); + body.appendChild(row); + + const actions = []; + if (be.hasWebUI && state.running) { + const port = (listenAddr || be.defaultListen).split(':').slice(-1)[0]; + actions.push(E('a', { + 'class': 'cbi-button cbi-button-action', + 'href': 'http://%s:%s'.format(window.location.hostname, port), + 'target': '_blank', + 'rel': 'noreferrer noopener' + }, _('Open WebUI'))); + } + if (state.running) { + const restart = E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Restart')); + restart.addEventListener('click', function(ev) { + ev.preventDefault(); + restart.disabled = true; + execInit(be, 'restart').finally(function() { restart.disabled = false; }); + }); + actions.push(restart); + } + if (be.name === 'dae') { + const hot = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Hot Reload')); + hot.addEventListener('click', function(ev) { + ev.preventDefault(); + hot.disabled = true; + execInit(be, 'hot_reload').finally(function() { hot.disabled = false; }); + }); + actions.push(hot); + } + if (state.running && be.name === 'dae') { + const ckBtn = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Test YouTube')); + const ckRes = E('span', { 'class': 'dd-meta', style: 'margin-left:8px;display:none' }, ''); + ckBtn.addEventListener('click', function(ev) { + ev.preventDefault(); + ckBtn.disabled = true; + ckRes.style.display = 'inline'; + ckRes.classList.remove('dd-ok', 'dd-err'); + ckRes.textContent = _('Testing…'); + /* actually probe YouTube through dae's transparent proxy */ + fs.exec('/usr/share/luci-app-daede/proxy-check.sh', []).then(function(r) { + const out = (r && r.stdout) || ''; + const ok = /\bok=1\b/.test(out); + const code = (out.match(/code=(\S+)/) || [])[1] || '000'; + const ms = parseInt((out.match(/ms=(\d+)/) || [])[1] || '0'); + if (ok) { + ckRes.classList.add('dd-ok'); + ckRes.textContent = _('YouTube reachable · %d ms').format(ms); + } else { + ckRes.classList.add('dd-err'); + ckRes.textContent = (code === '000') + ? _('YouTube unreachable (timeout)') + : _('YouTube unreachable (HTTP %s)').format(code); + } + }).catch(function() { + ckRes.classList.add('dd-err'); + ckRes.textContent = _('YouTube unreachable (timeout)'); + }).finally(function() { + ckBtn.disabled = false; + setTimeout(function() { ckRes.style.display = 'none'; }, 8000); + }); + }); + actions.push(ckBtn); + actions.push(ckRes); + } + if (actions.length) + body.appendChild(E('div', { 'class': 'dd-actions' }, actions)); + }; + + const refresh = function() { + return Promise.all([ + backend.serviceStatus(be.name), + backend.detectRunning() + ]).then(function(r) { + render(r[0], r[1]); + }); + }; + + poll.add(refresh); + refresh(); + return card; +} + +function wrapSettingsCard(title, descr, mapPromise, advTitle, advFields) { + return mapPromise.then(function(node) { + const advBody = E('div', { 'class': 'dd-adv-body' }); + const advWrap = E('div', { 'class': 'dd-adv dd-closed' }, [ + E('div', { 'class': 'dd-adv-bar' }, [ + E('span', {}, advTitle), + E('span', { 'class': 'dd-adv-chevron' }, '›') + ]), + advBody + ]); + advWrap.firstChild.addEventListener('click', function() { + advWrap.classList.toggle('dd-closed'); + }); + + (advFields || []).forEach(function(name) { + const row = node.querySelector('[data-name="' + name + '"]'); + if (row) advBody.appendChild(row); + }); + + const host = node.querySelector('.cbi-section') || node; + if (advBody.firstChild) host.appendChild(advWrap); + + const children = [ E('h4', { 'class': 'dd-card-title' }, title) ]; + if (descr) children.push(E('div', { 'class': 'dd-settings-descr' }, descr)); + children.push(node); + return E('div', { 'class': 'dd-card dd-settings-card' }, children); + }); +} + +function renderDaedSettings() { + let m, s, o; + m = new form.Map('daed', null, null); + + s = m.section(form.NamedSection, 'config', 'daed'); + s.addremove = false; + s.anonymous = true; + + o = s.option(form.Value, 'listen_addr', _('Listen Address'), + _('Host:port that the daed WebUI and GraphQL API listen on.')); + o.datatype = 'ipaddrport(1)'; + o.default = '0.0.0.0:2023'; + o.rmempty = false; + + o = s.option(form.Value, 'log_maxsize', _('Max Log Size (MB)'), + _('Rotate the log file once it grows past this many megabytes.')); + o.datatype = 'uinteger'; + o.default = '5'; + + o = s.option(form.Value, 'log_maxbackups', _('Max Log Backups'), + _('Number of rotated log files to keep.')); + o.datatype = 'uinteger'; + o.default = '1'; + + return wrapSettingsCard( + _('daede Settings'), + _('A modern dashboard for dae. Subscriptions, nodes, routing and DNS are managed in the daed WebUI.'), + m.render(), + _('Log Advanced Settings'), + ['log_maxsize', 'log_maxbackups'] + ).then(function(card) { + /* native Save/Apply footer is suppressed view-wide, so daed carries its + own primary action */ + const status = E('span', { 'class': 'dd-editor-status' }, ''); + const save = E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Save and Apply')); + save.addEventListener('click', function(ev) { + ev.preventDefault(); + save.disabled = true; + m.save(null, true) + .then(function() { return uci.save(); }) + .then(function() { + return uci.changes().then(function(ch) { + return (ch && Object.keys(ch).length) ? uci.apply() : null; + }); + }) + .then(function() { + /* no success popup — the reload is feedback enough */ + setTimeout(function() { window.location.reload(); }, 500); + }) + .catch(function(e) { + if (e && e.name === 'CBIValidationError') + ui.addNotification(null, E('p', _('Please fix the highlighted fields.')), 'warning'); + else + ui.addNotification(null, E('p', _('Save failed: %s').format(e.message || e)), 'danger'); + }) + .finally(function() { save.disabled = false; }); + }); + card.appendChild(E('div', { 'class': 'dd-editor-actions' }, [ save, status ])); + return card; + }); +} + +/* dae 示例里的占位订阅特征 —— example.com、relative/path/to 或中文占位文本 */ +const RE_PLACEHOLDER_URL = /(['"])(?:(?:https?|https-file|file):\/\/[^'"]*?(?:example\.com|relative\/path\/to)[^'"]*|你的订阅链接或者机场链接)\1/; + +/* 常用块片段 —— 给文本模式用户一键插入官方推荐结构 */ +const SNIPPETS = { + subscription: + '\nsubscription {\n' + + ' # 把下面这行换成你的机场订阅链接\n' + + " my_sub: '你的订阅链接或者机场链接'\n" + + '}\n', + node: + '\nnode {\n' + + ' # 单条节点链接(ss/vmess/trojan/vless 等 share link)\n' + + " # 'ss://...'\n" + + '}\n', + group: + '\ngroup {\n' + + ' my_group {\n' + + ' # 节点选择策略:min_moving_avg / min / random / fixed(0)\n' + + ' policy: min_moving_avg\n' + + ' }\n' + + '}\n', + routing: + '\nrouting {\n' + + ' # 私网直连\n' + + ' dip(geoip:private) -> direct\n' + + ' # 国内直连\n' + + ' dip(geoip:cn) -> direct\n' + + ' domain(geosite:cn) -> direct\n' + + ' # 兜底:走代理组\n' + + ' fallback: my_group\n' + + '}\n', + dns: + '\ndns {\n' + + ' upstream {\n' + + " alidns: 'udp://dns.alidns.com:53'\n" + + " googledns: 'tcp+udp://dns.google:53'\n" + + ' }\n' + + ' routing {\n' + + ' request {\n' + + ' qname(geosite:cn) -> alidns\n' + + ' fallback: googledns\n' + + ' }\n' + + ' }\n' + + '}\n', + include: + '\ninclude {\n' + + ' # 相对路径:相对于 dae -c 指定的入口配置目录\n' + + ' config.d/*.dae\n' + + '}\n' +}; + +/* 把 example.dae 的 subscription 块瘦身成「只保留 1 行占位 + 中文引导注释」, + 降低用户认知负担。其他块(global/node/group/routing/dns)原样保留。 */ +function simplifySubscriptionBlock(text) { + if (!text) return text; + const lines = text.split('\n'); + let start = -1, end = -1, depth = 0; + for (let i = 0; i < lines.length; i++) { + if (start < 0) { + if (/^\s*subscription\s*\{/.test(lines[i])) { + start = i; + depth = 1; + continue; + } + } else { + depth += (lines[i].match(/\{/g) || []).length; + depth -= (lines[i].match(/\}/g) || []).length; + if (depth <= 0) { end = i; break; } + } + } + if (start < 0 || end < 0) return text; + /* 整块替换:保留原有 block 缩进风格(4 空格) */ + const replacement = [ + 'subscription {', + ' # ⚠ 把下面这行的 URL 换成你机场的订阅链接,然后保存。', + " my_sub: '你的订阅链接或者机场链接'", + '}' + ]; + return lines.slice(0, start).concat(replacement).concat(lines.slice(end + 1)).join('\n'); +} + +function detectPlaceholders(text) { + if (!text) return []; + const lines = text.split('\n'); + const out = []; + for (let i = 0; i < lines.length; i++) { + const ln = lines[i]; + const stripped = ln.replace(/^\s*#.*$/, ''); + if (!stripped) continue; + const m = stripped.match(RE_PLACEHOLDER_URL); + if (m) out.push({ line: i + 1, url: m[2], raw: ln.trim() }); + } + return out; +} + +const GEN = '/usr/share/luci-app-daede/gen-dae-config.sh'; + +/* Table cell renderer: truncate long links so rows stay compact; full value + stays visible in the edit dialog and on hover. */ +function ellipsisCell(section_id) { + const v = this.cfgvalue(section_id) || ''; + const short = v.length > 52 ? v.slice(0, 52) + '…' : v; + return E('span', { 'title': v, 'style': 'font-family:ui-monospace,Menlo,monospace;font-size:12px' }, short); +} + +/* Wrap every form section in a collapsible accordion. openTitles lists the + section titles that should start expanded; the rest start collapsed. */ +function accordionizeSections(mapNode, openTitles) { + const secs = mapNode.querySelectorAll('.cbi-section'); + for (let i = 0; i < secs.length; i++) { + const sec = secs[i]; + const h = sec.querySelector('h3'); + const title = h ? h.textContent.trim() : _('Section'); + const open = (openTitles || []).indexOf(title) >= 0; + + const body = E('div', { 'class': 'dd-adv-body' }); + while (sec.firstChild) body.appendChild(sec.firstChild); + /* drop the moved

so it doesn't duplicate the accordion bar title */ + const dupTitle = body.querySelector('h3'); + if (dupTitle) dupTitle.parentNode.removeChild(dupTitle); + + const wrap = E('div', { 'class': 'dd-adv' + (open ? '' : ' dd-closed') }, [ + E('div', { 'class': 'dd-adv-bar' }, [ + E('span', {}, title), + E('span', { 'class': 'dd-adv-chevron' }, '›') + ]), + body + ]); + wrap.firstChild.addEventListener('click', function() { wrap.classList.toggle('dd-closed'); }); + sec.appendChild(wrap); + } +} + +/* Friendly form UI for the dae backend. Form is the source of truth: on save we + commit the `dae` UCI package, then gen-dae-config.sh renders config.dae, + validates it and hot-reloads. */ +function renderDaeForms(ctx) { + let m, s, o; + m = new form.Map('dae', null, null); + + /* Subscriptions */ + s = m.section(form.GridSection, 'subscription', _('Subscriptions'), + _('Airport / subscription links. dae resolves them into the node pool.')); + s.addremove = true; + s.anonymous = true; + s.sortable = false; + o = s.option(form.Value, 'tag', _('Tag')); + o.placeholder = 'my_sub'; + o = s.option(form.Value, 'url', _('Subscription URL')); + o.rmempty = false; + o.placeholder = 'https://example.com/sub'; + o.textvalue = ellipsisCell; /* keep long links from blowing up the row */ + o.validate = function(sid, v) { + if (!v) return _('URL is required'); + if (!/^(https?|https-file|file):\/\//.test(v)) return _('Must start with http(s):// or file://'); + return true; + }; + o = s.option(form.Flag, 'enabled', _('Enabled')); + o.default = '1'; + o.editable = true; + /* add an Update button next to Edit/Delete in each row; dae re-pulls all + subscriptions on reload, so the action is global regardless of the row */ + const origRowActions = s.renderRowActions; + s.renderRowActions = function(section_id, more_label) { + const cell = origRowActions.call(this, section_id, more_label); + /* only real subscription links (http/https) are re-fetchable; fixed share + links (vless:// ss:// …) are static. Keep the button on every row for a + consistent layout, but grey it out (disabled) on the fixed ones. */ + const subUrl = uci.get('dae', section_id, 'url') || ''; + const fetchable = /^https?:\/\//i.test(subUrl); + const btn = E('button', { + 'class': 'cbi-button cbi-button-action', + 'style': 'margin-right:.25em', + 'disabled': fetchable ? null : '', + 'title': fetchable ? '' : _('Fixed link — nothing to fetch.') + }, _('Update')); + if (fetchable) { + btn.addEventListener('click', function(ev) { + ev.preventDefault(); + btn.disabled = true; + backend.detectRunning().then(function(r) { + if (!r || !r.dae) + return ui.addNotification(null, E('p', _('dae is stopped; it fetches subscriptions on start.')), 'warning'); + return fs.exec(backend.BACKENDS.dae.initd, ['hot_reload']).then(function(res) { + if (res && res.code !== 0) + ui.addNotification(null, E('p', _('Update failed: %s').format(res.stderr || res.stdout || ('exit ' + res.code))), 'danger'); + else + ui.addNotification(null, E('p', _('Subscriptions updated (dae reloaded)')), 'info'); + }); + }).catch(function(e) { + ui.addNotification(null, E('p', _('Update failed: %s').format(e.message || e)), 'danger'); + }).finally(function() { btn.disabled = false; }); + }); + } + const firstBtn = cell.querySelector('button, a.cbi-button'); + if (firstBtn && firstBtn.parentNode) firstBtn.parentNode.insertBefore(btn, firstBtn); + else cell.appendChild(btn); + return cell; + }; + + /* Manual nodes (share links) */ + s = m.section(form.GridSection, 'node', _('Nodes'), + _('每行添加一个节点分享链接,支持 ss、vmess、vless、trojan、tuic、hysteria2、socks5。')); + s.addremove = true; + s.anonymous = true; + s.sortable = false; + o = s.option(form.Value, 'tag', _('Tag')); + o.placeholder = 'node1'; + o = s.option(form.Value, 'link', _('Share Link')); + o.rmempty = false; + o.placeholder = 'vmess://...'; + o.textvalue = ellipsisCell; + o = s.option(form.Flag, 'enabled', _('Enabled')); + o.default = '1'; + o.editable = true; + + /* Subscription / manual-node tags for the group filter dropdowns */ + const subTags = (uci.sections('dae', 'subscription') || []) + .map(function(x) { return x.tag; }).filter(function(x) { return !!x; }); + const nodeTags = (uci.sections('dae', 'node') || []) + .map(function(x) { return x.tag; }).filter(function(x) { return !!x; }); + + /* Groups (outbound) */ + s = m.section(form.TypedSection, 'group', _('Groups'), + _('Outbound groups. Leave the source empty to use all nodes. Set the route fallback to one of these.')); + s.addremove = true; + s.anonymous = true; + o = s.option(form.Value, 'name', _('Name')); + o.rmempty = false; + o.placeholder = 'proxy'; + o = s.option(form.ListValue, 'policy', _('Policy')); + o.value('min_moving_avg', _('Min moving average latency')); + o.value('min', _('Min last latency')); + o.value('min_avg10', _('Min average of last 10')); + o.value('random', _('Random')); + o.value('fixed(0)', _('Fixed (first node)')); + o.default = 'min_moving_avg'; + o = s.option(form.DynamicList, 'source', _('Source'), + _('Subscriptions and nodes that feed this group. Pick from the list or type a name. Leave empty to use all nodes.')); + subTags.forEach(function(t) { o.value(t, t + ' (' + _('subscription') + ')'); }); + nodeTags.forEach(function(t) { o.value(t, t + ' (' + _('node') + ')'); }); + o = s.option(form.DynamicList, 'name_filter', _('Name filter'), + _('Optional. Narrows subscriptions in the source to nodes whose name matches — e.g. 香港 or 新加坡|日本. Nodes you picked by name are always kept.')); + o.placeholder = '新加坡|日本'; + + /* Routing presets */ + const groupNames = (uci.sections('dae', 'group') || []) + .map(function(x) { return x.name; }).filter(function(x) { return !!x; }); + s = m.section(form.NamedSection, 'routing', 'routing', _('Routing')); + s.addremove = false; + o = s.option(form.Flag, 'private_direct', _('Private IPs direct'), + _('LAN / private addresses bypass the proxy.')); + o.default = '1'; + o = s.option(form.Flag, 'cn_direct', _('China direct'), + _('China mainland IPs and domains bypass the proxy.')); + o.default = '1'; + o = s.option(form.Flag, 'block_ads', _('Block ads'), + _('Drop traffic matching the ad domain list.')); + o.default = '0'; + o = s.option(form.Value, 'fallback', _('Fallback group'), + _('Default outbound for everything else.')); + if (groupNames.length) + groupNames.forEach(function(n) { o.value(n, n); }); + else + o.value('proxy', 'proxy'); + o = s.option(form.DynamicList, 'custom', _('Custom rules'), + _('Raw dae routing lines, evaluated before fallback. Target must be a group name above (or direct/block).')); + o.placeholder = 'dip(geoip:jp) -> proxy'; + + /* DNS presets */ + s = m.section(form.NamedSection, 'dns', 'dns', _('DNS')); + s.addremove = false; + o = s.option(form.Value, 'cn_upstream', _('China upstream'), + _('Resolves China-mainland domains.')); + o.default = 'udp://dns.alidns.com:53'; + o.placeholder = 'udp://dns.alidns.com:53'; + o = s.option(form.Value, 'fallback_upstream', _('Fallback upstream'), + _('Resolves everything else.')); + o.default = 'tcp+udp://dns.google:53'; + o.placeholder = 'tcp+udp://dns.google:53'; + + /* Logging — folded into the main form so the single Save button covers it + too (no separate native save bar) */ + s = m.section(form.NamedSection, 'config', 'dae', _('Logging')); + s.addremove = false; + o = s.option(form.Value, 'log_maxsize', _('Max Log Size (MB)'), + _('Rotate the log file once it grows past this many megabytes.')); + o.datatype = 'uinteger'; + o.default = '1'; + o = s.option(form.Value, 'log_maxbackups', _('Max Log Backups'), + _('Number of rotated log files to keep.')); + o.datatype = 'uinteger'; + o.default = '1'; + + const status = E('span', { 'class': 'dd-editor-status' }, ''); + let statusTimer = null; + function flash(text, kind, hold) { + status.textContent = text; + status.classList.remove('ok', 'err'); + if (kind) status.classList.add(kind); + status.classList.add('show'); + if (statusTimer) clearTimeout(statusTimer); + statusTimer = setTimeout(function() { status.classList.remove('show'); }, hold || 3000); + } + + const save = E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Save and Hot Reload')); + + return m.render().then(function(mapNode) { + save.addEventListener('click', function(ev) { + ev.preventDefault(); + save.disabled = true; + flash(_('Saving…')); + /* ensure the singleton sections exist before parsing the form — + on installs where uci-defaults never seeded them, writing an + option to a missing named section fails with ubus NOT_FOUND (4) */ + if (!uci.get('dae', 'routing')) uci.add('dae', 'routing', 'routing'); + if (!uci.get('dae', 'dns')) uci.add('dae', 'dns', 'dns'); + if (!uci.get('dae', 'config')) uci.add('dae', 'dae', 'config'); + /* commit dae UCI (apply flushes the rpc session to /etc/config, + which a CLI `uci commit` cannot see), then regenerate + hot reload */ + m.save(null, true) + .then(function() { return uci.save(); }) + .then(function() { + /* apply flushes the rpc session to /etc/config so the + generator (reads /etc/config/dae) sees the form data; + skip it when nothing changed — apply on an empty + changeset throws ubus NO_DATA (code 5). */ + return uci.changes().then(function(ch) { + return (ch && Object.keys(ch).length) ? uci.apply() : null; + }); + }) + .then(function() { return fs.exec(GEN, ['generate']); }) + .then(function(res) { + if (res && res.code !== 0) { + const err = (res.stderr || res.stdout || ('exit ' + res.code)).trim().split('\n')[0]; + flash(_('Apply failed: %s').format(err), 'err', 9000); + return; + } + /* reload to reflect committed state and clear the change badge */ + flash(_('Saved · validated · hot-reloaded'), 'ok'); + setTimeout(function() { window.location.reload(); }, 900); + }) + .catch(function(e) { + if (e && e.name === 'CBIValidationError') { + flash(_('Please fix the highlighted fields.'), 'err', 6000); + return; + } + flash(_('Save failed: %s').format(e.message || e), 'err', 9000); + }) + .finally(function() { save.disabled = false; }); + }); + + /* make each form section collapsible; keep the two everyday ones open */ + accordionizeSections(mapNode, [ _('Subscriptions'), _('Nodes') ]); + + const cardChildren = [ + E('h4', { 'class': 'dd-card-title' }, _('dae Configuration')), + E('div', { 'class': 'dd-settings-descr' }, + _('Add a subscription or node, then save — it takes effect automatically.')) + ]; + cardChildren.push(mapNode); + cardChildren.push(E('div', { 'class': 'dd-editor-actions' }, [ save, status ])); + + return E('div', { 'class': 'dd-card dd-settings-card' }, cardChildren); + }); +} + +/* Import banner: offer to pull subscription/node from an existing hand-written + config.dae into the form when the form is still empty. */ +function renderDaeImportBanner() { + const hasForms = (uci.sections('dae', 'subscription') || []).length + || (uci.sections('dae', 'node') || []).length; + if (hasForms) + return Promise.resolve(null); + + return fs.read_direct(backend.BACKENDS.dae.config, 'text').then(function(content) { + if (!content || !/\b(subscription|node)\s*\{/.test(content)) + return null; + + const btn = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Import from existing config')); + const note = E('span', { 'class': 'dd-meta', style: 'margin-left:8px' }, ''); + btn.addEventListener('click', function(ev) { + ev.preventDefault(); + btn.disabled = true; + note.textContent = _('Importing…'); + fs.exec(GEN, ['import']).then(function(res) { + if (res && res.code === 0) { + note.textContent = _('Imported, reloading…'); + setTimeout(function() { window.location.reload(); }, 600); + } else { + note.textContent = _('Import failed'); + btn.disabled = false; + } + }).catch(function() { note.textContent = _('Import failed'); btn.disabled = false; }); + }); + + return E('div', { 'class': 'dd-card', style: 'border-color:rgba(217,158,0,.4)' }, [ + E('div', { 'class': 'dd-ph-warn-title' }, _('Existing config detected')), + E('div', { 'class': 'dd-settings-descr', style: 'margin:4px 0 8px' }, + _('You have a hand-written config.dae. Import its subscriptions and nodes into the form, or keep editing manually below. Saving the form overwrites config.dae.')), + E('div', { 'class': 'dd-actions' }, [ btn, note ]) + ]); + }).catch(function() { return null; }); +} + +function renderDaeEditor() { + const textarea = E('textarea', { + 'class': 'dd-editor', + 'spellcheck': 'false', + 'placeholder': _('dae config file is empty. Click "Initialize from example" to start, or paste your config here.') + }, ''); + const save = E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Save manual config')); + const init = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Initialize from example')); + const status = E('span', { 'class': 'dd-editor-status' }, ''); + + const phWarn = E('div', { 'class': 'dd-ph-warn' }, [ + E('div', { 'class': 'dd-ph-warn-title' }, ''), + E('div', { 'class': 'dd-ph-howto' }, ''), + E('div', { 'class': 'dd-ph-list-label' }, _('Placeholder lines (click line number to jump):')), + E('ul', { 'class': 'dd-ph-list' }) + ]); + + /* Insert Block 下拉:选完后自动复位到首项 */ + const insertSelect = E('select', { 'class': 'dd-insert-select', 'title': _('Insert config block at cursor') }, [ + E('option', { 'value': '' }, _('+ Insert Block…')), + E('option', { 'value': 'subscription' }, 'subscription'), + E('option', { 'value': 'node' }, 'node'), + E('option', { 'value': 'group' }, 'group'), + E('option', { 'value': 'routing' }, 'routing'), + E('option', { 'value': 'dns' }, 'dns'), + E('option', { 'value': 'include' }, 'include') + ]); + + /* Footer:dae version · 行数 · 占位剩余 */ + const fbVer = E('span', { 'class': 'dd-fb-item' }, [ E('span', { 'class': 'dd-fb-key' }, 'dae'), E('span', {}, '—') ]); + const fbLine = E('span', { 'class': 'dd-fb-item' }, [ E('span', { 'class': 'dd-fb-key' }, _('lines')), E('span', {}, '0') ]); + const fbStat = E('span', { 'class': 'dd-fb-item dd-fb-ok' }, '✓ ready'); + const footer = E('div', { 'class': 'dd-editor-footer' }, [ fbVer, fbLine, fbStat ]); + + function insertAtCursor(text) { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const v = textarea.value; + textarea.value = v.slice(0, start) + text + v.slice(end); + const pos = start + text.length; + textarea.focus(); + textarea.setSelectionRange(pos, pos); + refreshPlaceholders(); + } + + insertSelect.addEventListener('change', function() { + const key = insertSelect.value; + insertSelect.value = ''; + if (key && SNIPPETS[key]) insertAtCursor(SNIPPETS[key]); + }); + + let statusTimer = null; + function flashStatus(text, kind, holdMs) { + status.textContent = text; + status.classList.remove('ok', 'err'); + if (kind) status.classList.add(kind); + status.classList.add('show'); + if (statusTimer) clearTimeout(statusTimer); + statusTimer = setTimeout(function() { + status.classList.remove('show'); + }, holdMs || 3000); + } + + function jumpToLine(lineNo) { + const lines = textarea.value.split('\n'); + let offset = 0; + for (let i = 0; i < lineNo - 1 && i < lines.length; i++) + offset += lines[i].length + 1; + textarea.focus(); + textarea.setSelectionRange(offset, offset + (lines[lineNo - 1] || '').length); + const lineHeight = 18; + textarea.scrollTop = Math.max(0, (lineNo - 4) * lineHeight); + } + + function refreshFooter(phCount) { + const lines = textarea.value ? textarea.value.split('\n').length : 0; + fbLine.lastChild.textContent = String(lines); + while (fbStat.firstChild) fbStat.removeChild(fbStat.firstChild); + fbStat.classList.remove('dd-fb-ok', 'dd-fb-warn'); + if (phCount > 0) { + fbStat.classList.add('dd-fb-warn'); + fbStat.textContent = _('⚠ %d placeholder').format(phCount); + } else { + fbStat.classList.add('dd-fb-ok'); + fbStat.textContent = lines > 0 ? '✓ ready' : '— empty'; + } + } + + function refreshPlaceholders() { + const hits = detectPlaceholders(textarea.value); + const titleEl = phWarn.querySelector('.dd-ph-warn-title'); + const howtoEl = phWarn.querySelector('.dd-ph-howto'); + const listEl = phWarn.querySelector('.dd-ph-list'); + while (listEl.firstChild) listEl.removeChild(listEl.firstChild); + refreshFooter(hits.length); + if (!hits.length) { + phWarn.classList.remove('show'); + return; + } + titleEl.textContent = _('⚠ 还差订阅链接才能跑:检测到 %d 行占位 URL').format(hits.length); + while (howtoEl.firstChild) howtoEl.removeChild(howtoEl.firstChild); + /* 用 DOM 拼成富文本说明 —— 比单条字符串可读 */ + howtoEl.appendChild(document.createTextNode(_('这些 '))); + const c1 = document.createElement('code'); c1.textContent = 'example.com'; howtoEl.appendChild(c1); + howtoEl.appendChild(document.createTextNode(_(' / '))); + const c2 = document.createElement('code'); c2.textContent = 'relative/path/to'; howtoEl.appendChild(c2); + howtoEl.appendChild(document.createTextNode(_(' 都是 dae 官方模板留的假地址,不是真订阅。把其中任意一行的 URL 换成你机场的订阅链接(删掉多余行),保存即可跑通。'))); + hits.forEach(function(h) { + const lnSpan = E('span', { 'class': 'dd-ph-ln', 'title': _('Jump to line') }, 'L' + h.line); + lnSpan.addEventListener('click', function() { jumpToLine(h.line); }); + const txt = E('span', { 'class': 'dd-ph-txt' }, h.url); + listEl.appendChild(E('li', {}, [ lnSpan, txt ])); + }); + phWarn.classList.add('show'); + } + + textarea.addEventListener('input', refreshPlaceholders); + + function loadConfig() { + return fs.read_direct(backend.BACKENDS.dae.config, 'text').then(function(content) { + textarea.value = content || ''; + }).catch(function() { + textarea.value = ''; + }).finally(refreshPlaceholders); + } + + /* 校验流程:先写到 /tmp/dae-validate.dae 干测 → dae validate 通过才覆盖正式 config */ + const VALIDATE_PATH = '/tmp/dae-validate.dae'; + + save.addEventListener('click', function(ev) { + ev.preventDefault(); + save.disabled = true; + flashStatus(_('Validating…')); + fs.write(VALIDATE_PATH, textarea.value, 384) + .then(function() { return fs.exec('/usr/bin/dae', ['validate', '-c', VALIDATE_PATH]); }) + .then(function(res) { + if (res && res.code !== 0) { + const err = (res.stderr || res.stdout || ('exit ' + res.code)).trim().split('\n')[0]; + flashStatus(_('Validate failed: %s').format(err), 'err', 8000); + throw new Error('validate-failed'); + } + flashStatus(_('Saving…')); + return fs.write(backend.BACKENDS.dae.config, textarea.value, 384); + }) + .then(function() { return backend.detectRunning(); }) + .then(function(running) { + if (running && running.dae) + return fs.exec(backend.BACKENDS.dae.initd, ['hot_reload']).then(function(res) { + if (res && res.code !== 0) + flashStatus(_('Reload failed: %s').format(res.stderr || res.stdout || ('exit ' + res.code)), 'err', 8000); + else + flashStatus(_('Validated · saved · hot-reloaded'), 'ok'); + }); + flashStatus(_('Saved OK (start dae to apply)'), 'ok'); + }) + .catch(function(e) { + if (e && e.message === 'validate-failed') return; + flashStatus(_('Save failed: %s').format(e.message || e), 'err', 8000); + }) + .finally(function() { + save.disabled = false; + fs.exec('/bin/rm', ['-f', VALIDATE_PATH]).catch(function() {}); + }); + }); + + init.addEventListener('click', function(ev) { + ev.preventDefault(); + if (textarea.value.trim() && !confirm(_('This will replace your current config. Continue?'))) + return; + init.disabled = true; + flashStatus(_('Loading example…')); + fs.read_direct(backend.BACKENDS.dae.example, 'text') + .then(function(content) { + textarea.value = simplifySubscriptionBlock(content || ''); + refreshPlaceholders(); + flashStatus(_('Example loaded — save to apply'), 'ok'); + }) + .catch(function(e) { flashStatus(_('Init failed: %s').format(e.message || e), 'err'); }) + .finally(function() { init.disabled = false; }); + }); + + /* 一次性探测 dae --version;失败就显示 unknown,不影响主流程 */ + fs.exec('/usr/bin/dae', ['--version']).then(function(res) { + const out = (res && res.stdout || '').trim(); + const m = out.match(/dae\s+version\s+(\S+)/); + fbVer.lastChild.textContent = m ? m[1].split('-')[0] : 'unknown'; + }).catch(function() { + fbVer.lastChild.textContent = 'unknown'; + }); + + loadConfig(); + + return E('div', { 'class': 'dd-card' }, [ + E('h4', { 'class': 'dd-card-title' }, _('Advanced / Manual Mode')), + E('div', { 'class': 'dd-settings-descr' }, + _('Edit config.dae directly. Saving the form above regenerates the file and overwrites changes made here.')), + (function() { + var adv = E('div', { 'class': 'dd-adv dd-closed', style: 'margin-top:8px' }, [ + E('div', { 'class': 'dd-adv-bar' }, [ + E('span', {}, _('Configuration Editor')), + E('span', { 'class': 'dd-adv-chevron' }, '›') + ]), + E('div', { 'class': 'dd-adv-body' }, [ + E('div', { 'class': 'dd-editor-hint', style: 'margin:0 0 10px' }, [ + E('b', {}, _('Text-only mode.')), + ' ', + _('Edit config DSL — subscriptions, nodes, routing, DNS. Load template via Initialize. Replace placeholder URL before saving. Switch to daed for GUI.') + ]), + phWarn, + textarea, + footer, + E('div', { 'class': 'dd-editor-actions' }, [ save, init, insertSelect, status ]) + ]) + ]); + adv.firstChild.addEventListener('click', function() { adv.classList.toggle('dd-closed'); }); + return adv; + })() + ]); +} + +return view.extend({ + load: function() { + return backend.detectBackend().then(function(ctx) { + return uci.load(ctx.backend.uci).catch(function() {}).then(function() { + return ctx; + }); + }); + }, + + render: function(ctx) { + const listenAddr = uci.get('daed', 'config', 'listen_addr') || backend.BACKENDS.daed.defaultListen; + const children = [ + E('style', {}, CSS), + renderStatusCard(ctx, listenAddr), + renderBackendSwitcher(ctx) + ].filter(function(node) { return !!node; }); + + if (!ctx.installed[ctx.name]) { + children.push(E('div', { 'class': 'dd-card dd-warning' }, _('Selected backend is not installed. Install dae or daed from the package feed first.'))); + } else if (ctx.name === 'dae') { + children.push(renderDaeImportBanner()); + children.push(renderDaeForms(ctx)); + children.push(renderDaeEditor()); + } else { + children.push(renderDaedSettings()); + } + + return Promise.all(children.map(function(child) { + return child && child.then ? child : Promise.resolve(child); + })).then(function(nodes) { + return E('div', { 'class': 'dd-wrap' }, nodes.filter(function(n) { return !!n; })); + }); + }, + + /* this view drives its own save buttons (dae form / daed settings / manual + editor) — suppress LuCI's global Save/Apply/Reset footer so there is one + unambiguous primary action per backend */ + handleSave: null, + handleSaveApply: null, + handleReset: null +}); diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/log.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/log.js new file mode 100644 index 000000000..1e438d9ad --- /dev/null +++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/log.js @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: Apache-2.0 + +'use strict'; +'require fs'; +'require poll'; +'require ui'; +'require view'; +'require view.daede.backend as backend'; + +const MAX_LINES = 5000; + +const CSS = [ + '.dd-log-wrap{padding:4px 0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}', + '.dd-log-card{border:1px solid rgba(0,0,0,.06);border-radius:10px;padding:10px 14px;margin-bottom:10px;box-shadow:0 2px 8px rgba(0,0,0,.03);background:rgba(255,255,255,.02)}', + '.dd-log-card-title{font-size:11px;font-weight:600;opacity:.55;margin:0 0 8px;letter-spacing:.3px;text-transform:uppercase}', + '.dd-log-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:0 0 8px;margin-bottom:8px;border-bottom:1px dashed rgba(128,128,128,.2)}', + '.dd-log-toolbar label{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;cursor:pointer;margin:0;opacity:.85}', + '.dd-log-toolbar input[type="checkbox"]{margin:0}', + '.dd-log-toolbar input[type="text"]{font-size:11.5px;padding:4px 8px;border-radius:5px;border:1px solid rgba(128,128,128,.28);background:transparent;color:inherit;min-width:160px}', + '.dd-log-toolbar .dd-log-btn{font-size:11.5px;padding:4px 12px;border-radius:5px;border:1px solid rgba(128,128,128,.28);background:transparent;color:inherit;cursor:pointer}', + '.dd-log-toolbar .dd-log-btn:hover{background:rgba(128,128,128,.1)}', + '.dd-log-toolbar .dd-log-meta{margin-left:auto;font-size:10.5px;opacity:.55;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}', + '.dd-log-pane{height:60vh;min-height:360px;overflow:auto;padding:8px 10px;border:1px solid rgba(0,0,0,.08);border-radius:7px;background:#1a1d21;color:#d8dde6;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace;font-size:11.5px;line-height:1.55;white-space:pre-wrap;word-break:break-all}', + '.dd-log-pane .dd-line{padding:1px 4px;border-radius:3px;display:block}', + '.dd-log-pane .dd-line.dd-info{color:#cfd6df}', + '.dd-log-pane .dd-line.dd-warn{color:#e8b95a}', + '.dd-log-pane .dd-line.dd-error{color:#ea7878;background:rgba(234,120,120,.06)}', + '.dd-log-pane .dd-line.dd-debug{color:#7a8290;opacity:.7}', + '.dd-log-pane .dd-line.dd-hidden{display:none}', + '.dd-log-pane .dd-empty{opacity:.5;font-style:italic}', + /* 简化后的字段视觉 */ + '.dd-log-pane .dd-ts{color:#6b7480;margin-right:8px}', + '.dd-log-pane .dd-lvl{display:inline-block;min-width:38px;padding:0 5px;margin-right:8px;border-radius:3px;font-size:10px;font-weight:700;letter-spacing:.4px;text-align:center;vertical-align:1px}', + '.dd-log-pane .dd-lvl-info{color:#7fc7a8;background:rgba(127,199,168,.08)}', + '.dd-log-pane .dd-lvl-warn{color:#e8b95a;background:rgba(232,185,90,.10)}', + '.dd-log-pane .dd-lvl-error{color:#ea7878;background:rgba(234,120,120,.10)}', + '.dd-log-pane .dd-lvl-debug{color:#7a8290;background:rgba(122,130,144,.10)}', + '.dd-log-pane .dd-msg{color:inherit}', + '.dd-log-pane .dd-kv{margin-left:6px;color:#6b7480;font-size:11px;opacity:.85}', + 'body.dark .dd-log-card,html[data-theme="dark"] .dd-log-card,html[data-bs-theme="dark"] .dd-log-card{border-color:rgba(255,255,255,.08);background:rgba(255,255,255,.02)}', + 'body.dark .dd-log-pane,html[data-theme="dark"] .dd-log-pane,html[data-bs-theme="dark"] .dd-log-pane{border-color:rgba(255,255,255,.1)}' +].join(''); + +/* 拆字段:time="May 25 07:04:59" level=info msg="..." key=val key="val with space" ... */ +const RE_LINE = /^time="([^"]*)"\s+level=(\w+)\s+msg=(?:"((?:[^"\\]|\\.)*)"|(\S+))\s*(.*)$/; + +function detectLevel(line) { + // daed/dae logs use lvl=info / [INFO] / level=warning style + const m = line.match(/\b(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|PANIC)\b/i); + if (!m) return ''; + const lvl = m[1].toUpperCase(); + if (lvl === 'DEBUG') return 'dd-debug'; + if (lvl === 'INFO') return 'dd-info'; + if (lvl.startsWith('WARN')) return 'dd-warn'; + return 'dd-error'; +} + +/* 简化时间戳并按本地时区显示: + * - daed 默认输出 UTC ISO 8601 (e.g. "2026-05-28T19:07:54Z"),转成本地时间 + * - 旧 logrus 短格式 (e.g. "May 25 07:04:59") 无时区信息,原样提取 + */ +function shortTs(raw) { + if (!raw) return raw; + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(raw)) { + const d = new Date(raw); + if (!isNaN(d.getTime())) { + const pad = n => String(n).padStart(2, '0'); + return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); + } + } + const m = raw.match(/(\d{2}:\d{2}:\d{2})/); + return m ? m[1] : raw; +} + +function lvlShort(level) { + const u = (level || '').toUpperCase(); + if (u === 'WARNING') return 'WARN'; + return u || '-'; +} + +function lvlClass(level) { + const u = (level || '').toUpperCase(); + if (u === 'DEBUG') return 'dd-lvl-debug'; + if (u === 'INFO') return 'dd-lvl-info'; + if (u === 'WARN' || u === 'WARNING') return 'dd-lvl-warn'; + return 'dd-lvl-error'; +} + +function buildLine(ln) { + const cls = detectLevel(ln); + const m = ln.match(RE_LINE); + if (!m) { + return E('div', { 'class': 'dd-line ' + cls }, ln); + } + const ts = shortTs(m[1]); + const lvl = m[2]; + const msg = m[3] !== undefined ? m[3] : (m[4] || ''); + const kv = (m[5] || '').trim(); + const parts = [ + E('span', { 'class': 'dd-ts' }, ts), + E('span', { 'class': 'dd-lvl ' + lvlClass(lvl) }, lvlShort(lvl)), + E('span', { 'class': 'dd-msg' }, msg) + ]; + if (kv) parts.push(E('span', { 'class': 'dd-kv' }, kv)); + return E('div', { 'class': 'dd-line ' + cls }, parts); +} + +return view.extend({ + load: function() { + return backend.detectBackend(); + }, + + render(ctx) { + const self = this; + const LOG_PATH = ctx.backend.log; + const state = { + lastContent: '', + lastSize: -1, + paused: false, + autoScroll: true, + filter: '', + userScrolled: false + }; + + const pane = E('div', { 'class': 'dd-log-pane', 'id': 'dd-log-pane' }, [ + E('div', { 'class': 'dd-empty' }, _('Loading…')) + ]); + + // detect manual scroll → auto-pause auto-scroll + pane.addEventListener('scroll', function() { + const atBottom = pane.scrollHeight - pane.scrollTop - pane.clientHeight < 4; + state.userScrolled = !atBottom; + }); + + const meta = E('span', { 'class': 'dd-log-meta' }, ''); + + const cbAuto = E('input', { 'type': 'checkbox', 'checked': 'checked' }); + cbAuto.addEventListener('change', function() { + state.autoScroll = cbAuto.checked; + if (state.autoScroll) { + pane.scrollTop = pane.scrollHeight; + state.userScrolled = false; + } + }); + + const cbPause = E('input', { 'type': 'checkbox' }); + cbPause.addEventListener('change', function() { + state.paused = cbPause.checked; + }); + + const selFilter = E('select', { 'class': 'dd-log-btn' }, [ + E('option', { 'value': '' }, _('All')), + E('option', { 'value': 'info' }, 'INFO'), + E('option', { 'value': 'warn' }, 'WARN'), + E('option', { 'value': 'error' }, 'ERROR'), + E('option', { 'value': 'alive' }, _('Node Status')), + E('option', { 'value': 'my_group' }, _('Proxy Traffic')) + ]); + selFilter.addEventListener('change', function() { + state.filter = selFilter.value; + applyFilter(); + }); + + const btnClear = E('button', { 'class': 'dd-log-btn' }, _('Clear View')); + btnClear.addEventListener('click', function() { + while (pane.firstChild) pane.removeChild(pane.firstChild); + state.lastContent = ''; + }); + + const btnDownload = E('button', { 'class': 'dd-log-btn' }, _('Download')); + btnDownload.addEventListener('click', function() { + const blob = new Blob([state.lastContent || ''], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = ctx.name + '-' + (new Date()).toISOString().replace(/[:.]/g, '-') + '.log'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }); + + const btnTruncate = E('button', { 'class': 'dd-log-btn' }, _('Clear File')); + btnTruncate.addEventListener('click', function() { + if (!confirm(_('Truncate %s log on the router? This cannot be undone.').format(ctx.name))) + return; + fs.write(LOG_PATH, '').then(function() { + state.lastContent = ''; + state.lastSize = 0; + }).catch(function() {}); + }); + + function applyFilter() { + const f = state.filter; + pane.querySelectorAll('.dd-line').forEach(function(el) { + if (!f || el.textContent.toLowerCase().indexOf(f) !== -1) + el.classList.remove('dd-hidden'); + else + el.classList.add('dd-hidden'); + }); + } + + function appendLines(text) { + const lines = text.split('\n'); + const frag = document.createDocumentFragment(); + for (let i = 0; i < lines.length; i++) { + const ln = lines[i]; + if (!ln) continue; + const el = buildLine(ln); + if (state.filter && ln.toLowerCase().indexOf(state.filter) === -1) + el.classList.add('dd-hidden'); + frag.appendChild(el); + } + // drop empty-state placeholder if present + const empty = pane.querySelector('.dd-empty'); + if (empty) pane.removeChild(empty); + pane.appendChild(frag); + + // cap rendered rows + const overflow = pane.children.length - MAX_LINES; + if (overflow > 0) { + for (let i = 0; i < overflow; i++) pane.removeChild(pane.firstChild); + } + } + + function renderEmpty(msg) { + while (pane.firstChild) pane.removeChild(pane.firstChild); + pane.appendChild(E('div', { 'class': 'dd-empty' }, msg)); + } + + function tick() { + if (state.paused) return Promise.resolve(); + + return fs.stat(LOG_PATH).then(function(st) { + const size = st.size || 0; + if (size === state.lastSize) { + meta.textContent = '%d bytes · live'.format(size); + return; + } + + // File rotated/truncated → full reload + const rotated = size < state.lastSize; + return fs.read_direct(LOG_PATH, 'text').then(function(content) { + content = content || ''; + let delta; + if (rotated || state.lastContent === '') { + // full replace + while (pane.firstChild) pane.removeChild(pane.firstChild); + delta = content; + } else if (content.indexOf(state.lastContent) === 0) { + // pure append + delta = content.slice(state.lastContent.length); + } else { + // content changed mid-stream → full replace + while (pane.firstChild) pane.removeChild(pane.firstChild); + delta = content; + } + + if (delta) appendLines(delta); + state.lastContent = content; + state.lastSize = size; + meta.textContent = '%d bytes · live'.format(size); + + if (state.autoScroll && !state.userScrolled) + pane.scrollTop = pane.scrollHeight; + }); + }).catch(function(e) { + const msg = String(e); + if (msg.indexOf('NotFoundError') !== -1 || msg.indexOf('No such') !== -1) + renderEmpty(_('Log file does not exist yet.')); + else + renderEmpty(_('Error reading log: %s').format(msg)); + state.lastSize = -1; + state.lastContent = ''; + meta.textContent = ''; + }); + } + + poll.add(tick); + tick(); + + const toolbar = E('div', { 'class': 'dd-log-toolbar' }, [ + E('label', {}, [ cbAuto, _('Auto-scroll') ]), + E('label', {}, [ cbPause, _('Pause') ]), + selFilter, + btnClear, + btnDownload, + btnTruncate, + meta + ]); + + return E('div', { 'class': 'dd-log-wrap' }, [ + E('style', {}, CSS), + E('div', { 'class': 'dd-log-card' }, [ + E('h4', { 'class': 'dd-log-card-title' }, _('%s Realtime Log').format(ctx.name)), + toolbar, + pane + ]) + ]); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/updates.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/updates.js new file mode 100644 index 000000000..6be52b680 --- /dev/null +++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/updates.js @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: Apache-2.0 + +'use strict'; +'require fs'; +'require poll'; +'require uci'; +'require ui'; +'require view'; +'require view.daede.backend as backend'; + +const DATA_PATHS = { + geoip: '/usr/share/v2ray/geoip.dat', + geosite: '/usr/share/v2ray/geosite.dat' +}; + +const HEALTH_PATHS = { + btf: '/sys/kernel/btf/vmlinux', + netns: '/run/netns/daens' +}; + +const CSS = [ + '.dd-up-wrap{padding:6px 0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}', + '.dd-card{border:1px solid rgba(0,0,0,.06);border-radius:10px;padding:14px 16px;margin-bottom:14px;box-shadow:0 2px 8px rgba(0,0,0,.03);background:rgba(255,255,255,.02)}', + '.dd-card-title{font-size:12px;font-weight:600;opacity:.55;margin:0 0 10px;letter-spacing:.3px;text-transform:uppercase}', + '.dd-up-row{display:grid;grid-template-columns:24px 130px 1fr auto auto;gap:10px;align-items:center;font-size:12px;padding:7px 0;border-top:1px dashed rgba(128,128,128,.18)}', + '.dd-up-row:first-of-type{border-top:0}', + '.dd-up-icon{font-size:14px;text-align:center;line-height:1}', + '.dd-up-ok{color:#3da66a}', + '.dd-up-warn{color:#d39e00}', + '.dd-up-err{color:#d96d6d}', + '.dd-up-name{font-weight:600;opacity:.85}', + '.dd-up-meta{opacity:.7;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', + '.dd-up-version{font-size:11px;opacity:.55;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;white-space:nowrap}', + '.dd-up-btn{font-size:11px;padding:4px 12px;border-radius:5px;border:1px solid rgba(128,128,128,.35);background:transparent;color:inherit;cursor:pointer;white-space:nowrap}', + '.dd-up-btn:hover{background:rgba(128,128,128,.12)}', + '.dd-up-btn:disabled{opacity:.45;cursor:not-allowed}', + '.dd-up-btn-primary{border-color:#4aa065;color:#4aa065}', + '.dd-up-log{margin-top:10px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:11px;padding:10px;border:1px solid rgba(128,128,128,.14);border-radius:8px;max-height:200px;overflow:auto;white-space:pre-wrap;word-break:break-all;display:none;background:inherit;color:#4a8c63}', + '.dd-up-log.show{display:block}', + 'body.dark .dd-up-log,html[data-theme="dark"] .dd-up-log,html[data-bs-theme="dark"] .dd-up-log{color:#a3d9ad}', + /* 手风琴折叠组 —— 与 config.js dd-adv 同构 */ + '.dd-adv{margin-bottom:14px}', + '.dd-adv-bar{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;cursor:pointer;user-select:none;font-size:11.5px;font-weight:600;background:rgba(128,128,128,.04);border:1px solid rgba(0,0,0,.06);border-radius:10px;color:inherit;letter-spacing:.3px;text-transform:uppercase;opacity:.7;box-shadow:0 2px 8px rgba(0,0,0,.03)}', + '.dd-adv-bar:hover{background:rgba(56,134,161,.06);opacity:.95}', + '.dd-adv-chevron{font-size:14px;font-weight:700;opacity:.55;transition:transform .2s;text-transform:none}', + '.dd-adv:not(.dd-closed) .dd-adv-chevron{transform:rotate(90deg)}', + '.dd-adv-body{margin-top:8px;padding:10px 14px;border:1px solid rgba(0,0,0,.06);border-radius:10px;background:rgba(255,255,255,.02);box-shadow:0 2px 8px rgba(0,0,0,.03)}', + '.dd-adv.dd-closed{margin-bottom:14px}', + '.dd-adv.dd-closed .dd-adv-body{display:none}', + 'body.dark .dd-card,html[data-theme="dark"] .dd-card,html[data-bs-theme="dark"] .dd-card,body.dark .dd-adv-bar,body.dark .dd-adv-body,html[data-theme="dark"] .dd-adv-bar,html[data-theme="dark"] .dd-adv-body,html[data-bs-theme="dark"] .dd-adv-bar,html[data-bs-theme="dark"] .dd-adv-body{border-color:rgba(255,255,255,.08);background:rgba(255,255,255,.02)}' +].join(''); + +function fmtBytes(n) { + if (!n && n !== 0) return '-'; + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + return (n / 1024 / 1024).toFixed(1) + ' MB'; +} + +function fmtMtime(epoch) { + if (!epoch) return ''; + const d = new Date(epoch * 1000); + return d.toISOString().slice(0, 10); +} + +function probeFile(path) { + return L.resolveDefault(fs.stat(path), null).then(function(st) { + return { exists: !!st, size: st ? st.size : 0, mtime: st ? st.mtime : 0 }; + }); +} + +function probePkg(pkg) { + return fs.exec('/usr/share/luci-app-daede/pkg-info.sh', [pkg]).then(function(res) { + const out = (res.stdout || '').trim().split('\t'); + return { installed: out[0] || '', latest: out[1] || '' }; + }).catch(function() { + return { installed: '', latest: '' }; + }); +} + +function tailLog(path, into) { + return L.resolveDefault(fs.read_direct(path, 'text'), '').then(function(content) { + if (content) { + into.textContent = content; + into.classList.add('show'); + } + }); +} + +return view.extend({ + load: function() { + return Promise.all([ + backend.detectBackend(), + uci.load('daed').catch(function() {}) + ]).then(function(r) { + return r[0]; + }); + }, + + render: function(ctx) { + const logPane = E('pre', { 'class': 'dd-up-log', 'id': 'dd-up-log' }, ''); + const corePkgs = []; + const addCorePkg = function(name) { + if (corePkgs.indexOf(name) === -1) + corePkgs.push(name); + }; + addCorePkg(ctx.name); + addCorePkg('daed'); + addCorePkg('dae'); + + const dataBody = E('div', { 'id': 'dd-up-data' }, E('em', {}, _('Probing…'))); + const pkgBody = E('div', { 'id': 'dd-up-pkg' }, E('em', {}, _('Probing…'))); + const healthBody = E('div', { 'id': 'dd-up-health' }, E('em', {}, _('Probing…'))); + + const mkRow = function(icon, iconCls, name, meta, btn) { + return E('div', { 'class': 'dd-up-row' }, [ + E('span', { 'class': 'dd-up-icon ' + iconCls }, icon), + E('span', { 'class': 'dd-up-name' }, name), + E('span', { 'class': 'dd-up-meta', 'title': meta }, meta), + E('span', {}, ''), + btn || E('span', {}, '') + ]); + }; + + // === Data updates (GeoIP / GeoSite) === + const updateGeo = function(kind, btn) { + const label = kind === 'geoip' ? 'GeoIP' : 'GeoSite'; + btn.disabled = true; + btn.textContent = '...'; + return fs.exec('/usr/share/luci-app-daede/update-geo.sh', [kind]).then(function(res) { + if (res.code === 0) { + setTimeout(function() { tailLog('/tmp/luci-app-daede.' + kind + '.log', logPane); }, 2000); + } + }).catch(function() {}).finally(function() { + btn.disabled = false; + btn.textContent = _('Update'); + }); + }; + + // === Package updates (dae|daed / luci-app-daede) === + const upgradePkg = function(pkg, btn) { + if (!confirm(_('Run "apk upgrade %s" now? This may restart the active backend.').format(pkg))) + return; + btn.disabled = true; + btn.textContent = '...'; + return fs.exec('/usr/share/luci-app-daede/update-pkg.sh', [pkg]).then(function(res) { + if (res.code === 0) { + setTimeout(function() { tailLog('/tmp/luci-app-daede.pkg-' + pkg + '.log', logPane); }, 3000); + } + }).catch(function() {}).finally(function() { + btn.disabled = false; + btn.textContent = _('Upgrade'); + }); + }; + + const refresh = function() { + const probes = [ + probeFile(DATA_PATHS.geoip), + probeFile(DATA_PATHS.geosite), + probeFile(HEALTH_PATHS.btf), + backend.detectRunning(), + backend.serviceStatus(ctx.name) + ]; + + corePkgs.forEach(function(pkg) { + probes.push(probePkg(pkg)); + }); + probes.push(probePkg('luci-app-daede')); + + if (ctx.backend.useNetns) + probes.push(probeFile(HEALTH_PATHS.netns)); + + return Promise.all(probes).then(function(r) { + const geoip = r[0], geosite = r[1], btf = r[2], running = r[3], service = r[4]; + const pkgOffset = 5; + const coreInfo = {}; + corePkgs.forEach(function(pkg, i) { + coreInfo[pkg] = r[pkgOffset + i]; + }); + const luci = r[pkgOffset + corePkgs.length]; + const ns = r[pkgOffset + corePkgs.length + 1]; + + // data rows + while (dataBody.firstChild) dataBody.removeChild(dataBody.firstChild); + [ + { k: 'geoip', name: 'GeoIP', r: geoip }, + { k: 'geosite', name: 'GeoSite', r: geosite } + ].forEach(function(entry) { + const btn = E('button', { 'class': 'dd-up-btn dd-up-btn-primary' }, _('Update')); + btn.addEventListener('click', function() { updateGeo(entry.k, btn); }); + const meta = entry.r.exists + ? fmtBytes(entry.r.size) + (entry.r.mtime ? ' · ' + fmtMtime(entry.r.mtime) : '') + : _('missing — click Update to fetch'); + dataBody.appendChild(mkRow( + entry.r.exists ? '✓' : '✗', + entry.r.exists ? 'dd-up-ok' : 'dd-up-err', + entry.name, + meta, + btn + )); + }); + + // pkg rows + while (pkgBody.firstChild) pkgBody.removeChild(pkgBody.firstChild); + corePkgs.map(function(pkg) { + const label = _('%s binary').format(pkg) + (ctx.name === pkg ? ' · ' + _('active') : ''); + return { k: pkg, name: label, r: coreInfo[pkg] }; + }).concat([ + { k: 'luci-app-daede', name: 'luci-app-daede', r: luci } + ]).forEach(function(entry) { + const btn = E('button', { 'class': 'dd-up-btn dd-up-btn-primary' }, _('Upgrade')); + btn.addEventListener('click', function() { upgradePkg(entry.k, btn); }); + const sameVersion = entry.r.installed && entry.r.latest && entry.r.installed === entry.r.latest; + const updatable = entry.r.installed && entry.r.latest && entry.r.installed !== entry.r.latest; + let meta; + if (!entry.r.installed) { + meta = _('not installed via package manager'); + btn.disabled = true; + btn.textContent = _('Unavailable'); + } else if (sameVersion) { + meta = _('installed') + ': ' + entry.r.installed + ' · ' + _('up to date'); + } else if (updatable) { + meta = _('installed') + ': ' + entry.r.installed + ' → ' + _('latest') + ': ' + entry.r.latest; + } else { + meta = _('installed') + ': ' + entry.r.installed + (entry.r.latest ? ' · ' + _('latest') + ': ' + entry.r.latest : ''); + } + pkgBody.appendChild(mkRow( + updatable ? '⚠' : (entry.r.installed ? '✓' : '✗'), + updatable ? 'dd-up-warn' : (entry.r.installed ? 'dd-up-ok' : 'dd-up-err'), + entry.name, + meta, + btn + )); + }); + + // health rows + while (healthBody.firstChild) healthBody.removeChild(healthBody.firstChild); + healthBody.appendChild(mkRow( + running[ctx.name] ? '✓' : '✗', + running[ctx.name] ? 'dd-up-ok' : 'dd-up-err', + _('%s process').format(ctx.name), + running[ctx.name] ? _('pgrep reports the backend is running') : _('backend process is not running') + )); + + if (btf.exists) + healthBody.appendChild(mkRow('✓', 'dd-up-ok', _('Kernel BTF'), HEALTH_PATHS.btf + ' · ' + fmtBytes(btf.size))); + else + healthBody.appendChild(mkRow('✗', 'dd-up-err', _('Kernel BTF'), _('not available — eBPF needs CONFIG_DEBUG_INFO_BTF or vmlinux-btf'))); + + if (ctx.backend.hasWebUI) { + const listenAddr = uci.get('daed', 'config', 'listen_addr') || ctx.backend.defaultListen; + const port = listenAddr.split(':').slice(-1)[0]; + healthBody.appendChild(mkRow( + service.running ? '✓' : '✗', + service.running ? 'dd-up-ok' : 'dd-up-err', + _('WebUI/API'), + service.running ? _('service is running, expected on port %s').format(port) : _('service is stopped') + )); + } else { + healthBody.appendChild(mkRow('✓', 'dd-up-ok', _('Hot reload'), _('/etc/init.d/dae hot_reload is available'))); + } + + if (ctx.backend.useNetns && ns && ns.exists) { + const btn = E('button', { 'class': 'dd-up-btn' }, _('Clean')); + btn.addEventListener('click', function() { + btn.disabled = true; + fs.exec('/sbin/ip', ['netns', 'del', 'daens']).finally(function() { btn.disabled = false; }); + }); + healthBody.appendChild(mkRow('⚠', 'dd-up-warn', _('netns daens'), HEALTH_PATHS.netns + ' · ' + _('exists, may block daed start'), btn)); + } else if (ctx.backend.useNetns) { + healthBody.appendChild(mkRow('✓', 'dd-up-ok', _('netns daens'), HEALTH_PATHS.netns + ' · ' + _('clean'))); + } + }); + }; + + poll.add(refresh); + refresh(); + + return E('div', { 'class': 'dd-up-wrap' }, [ + E('style', {}, CSS), + E('div', { 'class': 'dd-card' }, [ + E('h4', { 'class': 'dd-card-title' }, _('Data Updates')), + dataBody + ]), + E('div', { 'class': 'dd-card' }, [ + E('h4', { 'class': 'dd-card-title' }, _('Package Updates')), + pkgBody + ]), + (function() { + const adv = E('div', { 'class': 'dd-adv dd-closed' }, [ + E('div', { 'class': 'dd-adv-bar' }, [ + E('span', {}, _('System Health')), + E('span', { 'class': 'dd-adv-chevron' }, '›') + ]), + E('div', { 'class': 'dd-adv-body' }, [ healthBody ]) + ]); + adv.firstChild.addEventListener('click', function() { + adv.classList.toggle('dd-closed'); + }); + return adv; + })(), + logPane + ]); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/luci-app-daede/po/templates/daede.pot b/luci-app-daede/po/templates/daede.pot new file mode 100644 index 000000000..04b8641e7 --- /dev/null +++ b/luci-app-daede/po/templates/daede.pot @@ -0,0 +1,362 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8" + +msgid "Service Status" +msgstr "" + +msgid "Collecting data…" +msgstr "" + +msgid "RUNNING" +msgstr "" + +msgid "STOPPED" +msgstr "" + +msgid "Backend" +msgstr "" + +msgid "Listen" +msgstr "" + +msgid "Toggle service" +msgstr "" + +msgid "Open WebUI" +msgstr "" + +msgid "Restart" +msgstr "" + +msgid "Hot Reload" +msgstr "" + +msgid "Active backend" +msgstr "" + +msgid "Active" +msgstr "" + +msgid "Not installed" +msgstr "" + +msgid "Switch to %s" +msgstr "" + +msgid "%s is not installed" +msgstr "" + +msgid "%s is not installed. Install it first, then switch backend." +msgstr "" + +msgid "Switching backend stops the current service first. Click start when you want the new backend to run." +msgstr "" + +msgid "Stopping current backend…" +msgstr "" + +msgid "Switching to %s…" +msgstr "" + +msgid "Switched to %s. Click start when you want it to run." +msgstr "" + +msgid "Backend switched to %s. Click start to run it." +msgstr "" + +msgid "Switch failed." +msgstr "" + +msgid "Failed to switch backend: %s" +msgstr "" + +msgid "Action \"%s\" error: %s" +msgstr "" + +msgid "Action \"%s\" failed: %s" +msgstr "" + +msgid "Action \"%s\" succeeded." +msgstr "" + +msgid "Toggle failed: %s" +msgstr "" + +msgid "%s is already running. Stop %s before starting %s because both backends share the same eBPF/cgroup attachment." +msgstr "" + +msgid "daede Settings" +msgstr "" + +msgid "A modern dashboard for dae. Subscriptions, nodes, routing and DNS are managed in the daed WebUI." +msgstr "" + +msgid "Listen Address" +msgstr "" + +msgid "Host:port that the daed WebUI and GraphQL API listen on." +msgstr "" + +msgid "Max Log Size (MB)" +msgstr "" + +msgid "Rotate the log file once it grows past this many megabytes." +msgstr "" + +msgid "Max Log Backups" +msgstr "" + +msgid "Number of rotated log files to keep." +msgstr "" + +msgid "Log Advanced Settings" +msgstr "" + +msgid "dae Settings" +msgstr "" + +msgid "eBPF-based Linux high-performance transparent proxy solution." +msgstr "" + +msgid "Configuration File" +msgstr "" + +msgid "dae Configuration" +msgstr "" + +msgid "Text-only mode." +msgstr "" + +msgid "dae has no built-in WebUI. Edit the config DSL below by hand (subscriptions, nodes, routing, DNS). \"Initialize from example\" loads the bundled template — replace the placeholder subscription URL with your own before saving. For point-and-click management, switch backend to daed." +msgstr "" + +msgid "dae config file is empty. Click \"Initialize from example\" to start, or paste your config here." +msgstr "" + +msgid "Save and Hot Reload" +msgstr "" + +msgid "Initialize from example" +msgstr "" + +msgid "+ Insert Block…" +msgstr "" + +msgid "Insert config block at cursor" +msgstr "" + +msgid "lines" +msgstr "" + +msgid "⚠ %d placeholder" +msgstr "" + +msgid "⚠ 还差订阅链接才能跑:检测到 %d 行占位 URL" +msgstr "" + +msgid " / " +msgstr "" + +msgid "这些 " +msgstr "" + +msgid " 都是 dae 官方模板留的假地址,不是真订阅。把其中任意一行的 URL 换成你机场的订阅链接(删掉多余行),保存即可跑通。" +msgstr "" + +msgid "Placeholder lines (click line number to jump):" +msgstr "" + +msgid "Jump to line" +msgstr "" + +msgid "Validating…" +msgstr "" + +msgid "Validate failed: %s" +msgstr "" + +msgid "Saving…" +msgstr "" + +msgid "Reload failed: %s" +msgstr "" + +msgid "Validated · saved · hot-reloaded" +msgstr "" + +msgid "Save failed: %s" +msgstr "" + +msgid "Loading example…" +msgstr "" + +msgid "Example loaded" +msgstr "" + +msgid "Init failed: %s" +msgstr "" + +msgid "Selected backend is not installed. Install dae or daed from the package feed first." +msgstr "" + +msgid "Loading…" +msgstr "" + +msgid "Log file does not exist yet." +msgstr "" + +msgid "Error reading log: %s" +msgstr "" + +msgid "Log file cleared." +msgstr "" + +msgid "Failed to clear log: %s" +msgstr "" + +msgid "Truncate %s log on the router? This cannot be undone." +msgstr "" + +msgid "Settings" +msgstr "" + +msgid "Updates" +msgstr "" + +msgid "Log" +msgstr "" + +msgid "Data Updates" +msgstr "" + +msgid "Package Updates" +msgstr "" + +msgid "System Health" +msgstr "" + +msgid "Update" +msgstr "" + +msgid "Upgrade" +msgstr "" + +msgid "Clean" +msgstr "" + +msgid "missing — click Update to fetch" +msgstr "" + +msgid "%s download started — will refresh in 1–3 min." +msgstr "" + +msgid "%s update already running." +msgstr "" + +msgid "%s update failed: %s" +msgstr "" + +msgid "%s update error: %s" +msgstr "" + +msgid "%s binary" +msgstr "" + +msgid "active" +msgstr "" + +msgid "installed" +msgstr "" + +msgid "latest" +msgstr "" + +msgid "up to date" +msgstr "" + +msgid "not installed via package manager" +msgstr "" + +msgid "Unavailable" +msgstr "" + +msgid "Run \"apk upgrade %s\" now? This may restart the active backend." +msgstr "" + +msgid "%s upgrade started — see log below." +msgstr "" + +msgid "%s upgrade already running." +msgstr "" + +msgid "%s upgrade failed: %s" +msgstr "" + +msgid "%s upgrade error: %s" +msgstr "" + +msgid "Probing…" +msgstr "" + +msgid "%s process" +msgstr "" + +msgid "Kernel BTF" +msgstr "" + +msgid "WebUI/API" +msgstr "" + +msgid "Hot reload" +msgstr "" + +msgid "netns daens" +msgstr "" + +msgid "pgrep reports the backend is running" +msgstr "" + +msgid "backend process is not running" +msgstr "" + +msgid "service is running, expected on port %s" +msgstr "" + +msgid "service is stopped" +msgstr "" + +msgid "not available — eBPF needs CONFIG_DEBUG_INFO_BTF or vmlinux-btf" +msgstr "" + +msgid "/etc/init.d/dae hot_reload is available" +msgstr "" + +msgid "netns daens removed." +msgstr "" + +msgid "exists, may block daed start" +msgstr "" + +msgid "clean" +msgstr "" + +msgid "%s Realtime Log" +msgstr "" + +msgid "Auto-scroll" +msgstr "" + +msgid "Pause" +msgstr "" + +msgid "Filter (substring)" +msgstr "" + +msgid "Clear View" +msgstr "" + +msgid "Clear File" +msgstr "" + +msgid "Download" +msgstr "" diff --git a/luci-app-daede/po/zh-cn/daede.po b/luci-app-daede/po/zh-cn/daede.po new file mode 100644 index 000000000..53ced6135 --- /dev/null +++ b/luci-app-daede/po/zh-cn/daede.po @@ -0,0 +1,653 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Project-Id-Version: PACKAGE VERSION\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: zh-Hans\n" +"MIME-Version: 1.0\n" +"Content-Transfer-Encoding: 8bit\n" + +# ---- config.js: Service Status ---- +msgid "Service Status" +msgstr "服务状态" + +msgid "Collecting data…" +msgstr "正在收集数据…" + +msgid "RUNNING" +msgstr "运行中" + +msgid "STOPPED" +msgstr "已停止" + +msgid "Backend" +msgstr "后端" + +msgid "Listen" +msgstr "监听" + +msgid "Toggle service" +msgstr "开关服务" + +msgid "Open WebUI" +msgstr "打开 WebUI" + +msgid "Restart" +msgstr "重启" + +msgid "Hot Reload" +msgstr "热重载" + +# ---- config.js: Backend Switcher ---- +msgid "Active backend" +msgstr "当前后端" + +msgid "Active" +msgstr "当前" + +msgid "Not installed" +msgstr "未安装" + +msgid "Switch to %s" +msgstr "切换到 %s" + +msgid "%s is not installed" +msgstr "%s 未安装" + +msgid "%s is not installed. Install it first, then switch backend." +msgstr "%s 未安装。请先安装再切换后端。" + +msgid "Switching backend stops the current service first. Click start when you want the new backend to run." +msgstr "切换后端会先停止当前服务。点击开关启动新后端即可。" + +msgid "Stopping current backend…" +msgstr "停止当前后端中…" + +msgid "Switching to %s…" +msgstr "切换至 %s 中…" + +msgid "Switched to %s. Click start when you want it to run." +msgstr "已切换至 %s。点击开关启动。" + +msgid "Backend switched to %s. Click start to run it." +msgstr "已切换至 %s。点击开关启动。" + +msgid "Switch failed." +msgstr "切换失败。" + +msgid "Failed to switch backend: %s" +msgstr "切换后端失败:%s" + +# ---- config.js: Init Actions ---- +msgid "Action \"%s\" error: %s" +msgstr "操作 \"%s\" 出错:%s" + +msgid "Action \"%s\" failed: %s" +msgstr "操作 \"%s\" 失败:%s" + +msgid "Action \"%s\" succeeded." +msgstr "操作 \"%s\" 成功。" + +msgid "Toggle failed: %s" +msgstr "开关切换失败:%s" + +msgid "%s is already running. Stop %s before starting %s because both backends share the same eBPF/cgroup attachment." +msgstr "%s 已在运行。%s 与 %s 共用 eBPF/cgroup 挂载点,请先停止前者。" + +# ---- config.js: daede Settings ---- +msgid "daede Settings" +msgstr "daede 设置" + +msgid "A modern dashboard for dae. Subscriptions, nodes, routing and DNS are managed in the daed WebUI." +msgstr "dae 的现代化控制台。订阅、节点、路由和 DNS 请在 daed WebUI 中管理。" + +msgid "Listen Address" +msgstr "监听地址" + +msgid "Host:port that the daed WebUI and GraphQL API listen on." +msgstr "daed WebUI 与 GraphQL API 监听的 Host:Port。" + +msgid "Max Log Size (MB)" +msgstr "单文件大小上限(MB)" + +msgid "Rotate the log file once it grows past this many megabytes." +msgstr "单个日志文件超过此大小后轮转。" + +msgid "Max Log Backups" +msgstr "保留旧日志数" + +msgid "Number of rotated log files to keep." +msgstr "轮转后保留的旧日志文件数。" + +msgid "Log Advanced Settings" +msgstr "日志高级设置" + +# ---- config.js: dae Settings ---- +msgid "dae Parameters" +msgstr "dae 参数" + +msgid "Configuration file path and log rotation settings for dae." +msgstr "dae 配置文件路径与日志轮转参数。" + +msgid "Configuration File" +msgstr "配置文件" + +# ---- config.js: dae Editor ---- +msgid "dae Configuration" +msgstr "dae 配置" + +msgid "Text-only mode." +msgstr "纯文本模式。" + +msgid "dae has no built-in WebUI. Edit the config DSL below by hand (subscriptions, nodes, routing, DNS). \"Initialize from example\" loads the bundled template — replace the placeholder subscription URL with your own before saving. For point-and-click management, switch backend to daed." +msgstr "dae 没有内置 WebUI,下面的 DSL 配置需要手工编辑(订阅、节点、路由、DNS)。「从示例初始化」会载入官方模板——保存前请把占位的订阅链接换成你自己的。想图形化点选管理,请把后端切回 daed。" + +msgid "dae config file is empty. Click \"Initialize from example\" to start, or paste your config here." +msgstr "dae 配置文件为空。点「从示例初始化」开始,或直接粘贴你的配置。" + +# ---- config.js: dae Parameters ---- +msgid "dae Parameters" +msgstr "dae 参数" + +msgid "Edit config DSL — subscriptions, nodes, routing, DNS. Load template via Initialize. Replace placeholder URL before saving. Switch to daed for GUI." +msgstr "编辑 dae DSL 配置——订阅、节点、路由、DNS。通过「从示例初始化」载入模板。保存前务必替换占位订阅链接。图形化管理请切换至 daed。" + +msgid "Save and Hot Reload" +msgstr "保存并热重载" + +msgid "Initialize from example" +msgstr "从示例初始化" + +msgid "+ Insert Block…" +msgstr "+ 插入块…" + +msgid "Insert config block at cursor" +msgstr "在光标处插入配置块" + +msgid "lines" +msgstr "行" + +msgid "⚠ %d placeholder" +msgstr "⚠ %d 处占位" + +msgid "⚠ 还差订阅链接才能跑:检测到 %d 行占位 URL" +msgstr "⚠ 还差订阅链接才能跑:检测到 %d 行占位 URL" + +msgid " / " +msgstr " / " + +msgid "这些 " +msgstr "这些 " + +msgid " 都是 dae 官方模板留的假地址,不是真订阅。把其中任意一行的 URL 换成你机场的订阅链接(删掉多余行),保存即可跑通。" +msgstr " 都是 dae 官方模板留的假地址,不是真订阅。把其中任意一行的 URL 换成你机场的订阅链接(删掉多余行),保存即可跑通。" + +msgid "Placeholder lines (click line number to jump):" +msgstr "占位行(点行号跳转):" + +msgid "Jump to line" +msgstr "跳转到该行" + +msgid "Validating…" +msgstr "校验中…" + +msgid "Validate failed: %s" +msgstr "配置校验失败:%s" + +msgid "Saving…" +msgstr "保存中…" + +msgid "Reload failed: %s" +msgstr "热重载失败:%s" + +msgid "Validated · saved · hot-reloaded" +msgstr "已校验 · 已保存 · 已热重载" + +msgid "Save failed: %s" +msgstr "保存失败:%s" + +msgid "Loading example…" +msgstr "加载示例中…" + +msgid "Example loaded" +msgstr "示例已加载" + +msgid "Init failed: %s" +msgstr "初始化失败:%s" + +msgid "Selected backend is not installed. Install dae or daed from the package feed first." +msgstr "当前后端未安装,请从软件源安装 dae 或 daed。" + +# ---- log.js ---- +msgid "Loading…" +msgstr "加载中…" + +msgid "Log file does not exist yet." +msgstr "日志文件尚不存在。" + +msgid "Error reading log: %s" +msgstr "读取日志出错:%s" + +msgid "Log file cleared." +msgstr "日志文件已清空。" + +msgid "Failed to clear log: %s" +msgstr "清空日志失败:%s" + +msgid "Truncate %s log on the router? This cannot be undone." +msgstr "确定要清空路由器上的 %s 日志吗?此操作不可恢复。" + +# ---- updates.js: Data Updates ---- +msgid "Settings" +msgstr "设置" + +msgid "Updates" +msgstr "更新" + +msgid "Log" +msgstr "日志" + +msgid "Data Updates" +msgstr "数据更新" + +msgid "Package Updates" +msgstr "软件包更新" + +msgid "System Health" +msgstr "系统健康" + +msgid "Update" +msgstr "更新" + +msgid "Upgrade" +msgstr "升级" + +msgid "Clean" +msgstr "清理" + +msgid "missing — click Update to fetch" +msgstr "缺失——点 Update 下载" + +msgid "%s download started — will refresh in 1–3 min." +msgstr "%s 下载已开始——1–3 分钟后刷新。" + +msgid "%s update already running." +msgstr "%s 更新已在运行。" + +msgid "%s update failed: %s" +msgstr "%s 更新失败:%s" + +msgid "%s update error: %s" +msgstr "%s 更新出错:%s" + +# ---- updates.js: Package Updates ---- +msgid "%s binary" +msgstr "%s 内核" + +msgid "active" +msgstr "当前" + +msgid "installed" +msgstr "已安装" + +msgid "latest" +msgstr "最新" + +msgid "up to date" +msgstr "已是最新" + +msgid "not installed via package manager" +msgstr "非包管理器安装" + +msgid "Unavailable" +msgstr "不可用" + +msgid "Run \"apk upgrade %s\" now? This may restart the active backend." +msgstr "立即运行 \"apk upgrade %s\"?这可能会重启当前后端。" + +msgid "%s upgrade started — see log below." +msgstr "%s 升级已开始——见下方日志。" + +msgid "%s upgrade already running." +msgstr "%s 升级已在运行。" + +msgid "%s upgrade failed: %s" +msgstr "%s 升级失败:%s" + +msgid "%s upgrade error: %s" +msgstr "%s 升级出错:%s" + +# ---- updates.js: System Health ---- +msgid "Probing…" +msgstr "检测中…" + +msgid "%s process" +msgstr "%s 进程" + +msgid "Kernel BTF" +msgstr "内核 BTF" + +msgid "WebUI/API" +msgstr "WebUI/API" + +msgid "Hot reload" +msgstr "热重载" + +msgid "netns daens" +msgstr "netns daens" + +msgid "pgrep reports the backend is running" +msgstr "pgrep 检测到后端正在运行" + +msgid "backend process is not running" +msgstr "后端进程未运行" + +msgid "service is running, expected on port %s" +msgstr "服务运行中,预期监听端口 %s" + +msgid "service is stopped" +msgstr "服务已停止" + +msgid "not available — eBPF needs CONFIG_DEBUG_INFO_BTF or vmlinux-btf" +msgstr "不可用——eBPF 需要 CONFIG_DEBUG_INFO_BTF 或 vmlinux-btf" + +msgid "/etc/init.d/dae hot_reload is available" +msgstr "dae 热重载可用" + +msgid "netns daens removed." +msgstr "netns daens 已清理。" + +msgid "exists, may block daed start" +msgstr "已存在,可能阻碍 daed 启动" + +msgid "clean" +msgstr "干净" + +# ---- log.js toolbar ---- +msgid "%s Realtime Log" +msgstr "%s 实时日志" + +msgid "Auto-scroll" +msgstr "自动滚动" + +msgid "Pause" +msgstr "暂停" + +msgid "Filter (substring)" +msgstr "过滤(子串)" + +msgid "Clear View" +msgstr "清空显示" + +msgid "Clear File" +msgstr "清空文件" + +msgid "Download" +msgstr "下载" + +# ---- config.js: Subscription Card ---- +msgid "Subscription Management" +msgstr "订阅管理" + +msgid "Paste subscription URL here" +msgstr "在此粘贴订阅链接" + +msgid "Apply and Save" +msgstr "应用并保存" + +msgid "Applying…" +msgstr "应用…" + +msgid "Saved, start dae to apply" +msgstr "已保存,启动 dae 后生效" + +msgid "Saved and applied" +msgstr "已保存并应用" + +msgid "Saved, reload failed" +msgstr "已保存,重载失败" + +msgid "Not found, load example first" +msgstr "未找到订阅行,请先载入示例" + +msgid "Failed" +msgstr "失败" + +msgid "This will replace your current config. Continue?" +msgstr "这将覆盖当前配置,继续?" + +msgid "Example loaded — save to apply" +msgstr "示例已载入,保存后生效" + +msgid "Saved OK (start dae to apply)" +msgstr "已保存(启动 dae 后生效)" + +msgid "Configuration Editor" +msgstr "配置编辑" + +msgid "Edit config DSL — subscriptions, nodes, routing, DNS. Load template via Initialize. Replace placeholder URL before saving. Switch to daed for GUI." +msgstr "编辑 dae DSL 配置——订阅、节点、路由、DNS。通过「从示例初始化」载入模板。保存前务必替换占位订阅链接。图形化管理请切换至 daed。" + +# ---- config.js: Proxy Health ---- +msgid "Test YouTube" +msgstr "测试 YouTube" + +msgid "Testing…" +msgstr "测试中…" + +msgid "YouTube reachable · %d ms" +msgstr "YouTube 可访问 · %d ms" + +msgid "YouTube unreachable (timeout)" +msgstr "YouTube 无法访问(超时)" + +msgid "YouTube unreachable (HTTP %s)" +msgstr "YouTube 无法访问(HTTP %s)" + +# ---- config.js: dae friendly form ---- +msgid "Subscriptions" +msgstr "订阅" + +msgid "Airport / subscription links. dae resolves them into the node pool." +msgstr "机场 / 订阅链接,dae 会解析成节点池里的节点。" + +msgid "Tag" +msgstr "标签" + +msgid "Subscription URL" +msgstr "订阅链接" + +msgid "URL is required" +msgstr "订阅链接不能为空" + +msgid "Must start with http(s):// or file://" +msgstr "需以 http(s):// 或 file:// 开头" + +msgid "Enabled" +msgstr "启用" + +msgid "Nodes" +msgstr "节点" + +msgid "Single share links (ss / vmess / vless / trojan / tuic / hysteria2 / socks5 …)." +msgstr "每行添加一个节点分享链接,支持 ss、vmess、vless、trojan、tuic、hysteria2、socks5。" + +msgid "Share Link" +msgstr "分享链接" + +msgid "Groups" +msgstr "分组" + +msgid "Outbound groups. Leave the source empty to use all nodes. Set the route fallback to one of these." +msgstr "出站分组。来源留空表示用全部节点。路由兜底要指向其中一个分组。" + +msgid "Name" +msgstr "名称" + +msgid "Policy" +msgstr "策略" + +msgid "Min moving average latency" +msgstr "最小移动平均延迟" + +msgid "Min last latency" +msgstr "最小最近延迟" + +msgid "Min average of last 10" +msgstr "最近 10 次最小均值" + +msgid "Random" +msgstr "随机" + +msgid "Fixed (first node)" +msgstr "固定(第一个节点)" + +msgid "Source" +msgstr "来源" + +msgid "Subscriptions and nodes that feed this group. Pick from the list or type a name. Leave empty to use all nodes." +msgstr "进入这个分组的订阅和节点。从列表里选,或手输名称。留空=用全部节点。" + +msgid "subscription" +msgstr "订阅" + +msgid "node" +msgstr "节点" + +msgid "Name filter" +msgstr "名称筛选" + +msgid "Optional. Narrows subscriptions in the source to nodes whose name matches — e.g. 香港 or 新加坡|日本. Nodes you picked by name are always kept." +msgstr "可选。只对来源里的订阅做收窄,留下名称匹配的节点,如 香港 或 新加坡|日本;你手动挑的单个节点始终保留。" + +msgid "Routing" +msgstr "路由" + +msgid "Private IPs direct" +msgstr "私网 IP 直连" + +msgid "LAN / private addresses bypass the proxy." +msgstr "局域网 / 私有地址不走代理。" + +msgid "China direct" +msgstr "国内直连" + +msgid "China mainland IPs and domains bypass the proxy." +msgstr "中国大陆 IP 和域名不走代理。" + +msgid "Block ads" +msgstr "拦截广告" + +msgid "Drop traffic matching the ad domain list." +msgstr "丢弃命中广告域名表的流量。" + +msgid "Fallback group" +msgstr "兜底分组" + +msgid "Default outbound for everything else." +msgstr "其余流量的默认出站。" + +msgid "Custom rules" +msgstr "自定义规则" + +msgid "Raw dae routing lines, evaluated before fallback. Target must be a group name above (or direct/block)." +msgstr "原始 dae 路由行,在兜底前生效。箭头右边要填上面的分组名(或 direct / block)。" + +msgid "China upstream" +msgstr "国内 DNS 上游" + +msgid "Resolves China-mainland domains." +msgstr "解析中国大陆域名。" + +msgid "Fallback upstream" +msgstr "兜底 DNS 上游" + +msgid "Resolves everything else." +msgstr "解析其余域名。" + +msgid "Add a subscription or node, then save — it takes effect automatically." +msgstr "填好订阅或节点,点保存就自动生效。" + +msgid "Apply failed: %s" +msgstr "应用失败:%s" + +msgid "Saved · validated · hot-reloaded" +msgstr "已保存 · 已校验 · 已热重载" + +msgid "Please fix the highlighted fields." +msgstr "请先修正高亮的字段。" + +msgid "Import from existing config" +msgstr "从现有配置导入" + +msgid "Existing config detected" +msgstr "检测到已有配置" + +msgid "You have a hand-written config.dae. Import its subscriptions and nodes into the form, or keep editing manually below. Saving the form overwrites config.dae." +msgstr "你已有手写的 config.dae。可把里面的订阅和节点导入表单,或在下方继续手动编辑。表单保存会覆盖 config.dae。" + +msgid "Importing…" +msgstr "导入中…" + +msgid "Imported, reloading…" +msgstr "已导入,正在刷新…" + +msgid "Import failed" +msgstr "导入失败" + +msgid "Advanced / Manual Mode" +msgstr "高级 / 手动模式" + +msgid "Edit config.dae directly. Saving the form above regenerates the file and overwrites changes made here." +msgstr "直接编辑 config.dae。上方表单保存会重新生成文件并覆盖这里的改动。" + +msgid "Update Subscriptions" +msgstr "更新订阅" + +msgid "Re-fetch all airport subscriptions (reloads dae)" +msgstr "重新拉取所有机场订阅(重载 dae)" + +msgid "Updating subscriptions…" +msgstr "更新订阅中…" + +msgid "dae is stopped; it fetches subscriptions on start." +msgstr "dae 未运行,启动时会自动拉取订阅。" + +msgid "Update failed: %s" +msgstr "更新失败:%s" + +msgid "Subscriptions updated (dae reloaded)" +msgstr "订阅已更新(dae 已重载)" + +msgid "Section" +msgstr "区块" + +msgid "DNS" +msgstr "DNS" + +msgid "dae config file is empty. Click \"Initialize from example\" to start, or paste your config here." +msgstr "dae 配置文件为空。点击「从示例初始化」开始,或直接在此粘贴你的配置。" + +# ---- log.js: filter dropdown ---- +msgid "All" +msgstr "全部" + +msgid "Node Status" +msgstr "节点状态" + +msgid "Proxy Traffic" +msgstr "代理流量" + + +msgid "Logging" +msgstr "日志" + +msgid "Save manual config" +msgstr "保存手动配置" + +msgid "Save and Apply" +msgstr "保存并应用" + +msgid "Fixed link — nothing to fetch." +msgstr "固定链接,无需更新。" diff --git a/luci-app-daede/po/zh_Hans/daede.po b/luci-app-daede/po/zh_Hans/daede.po new file mode 100644 index 000000000..bde2dd655 --- /dev/null +++ b/luci-app-daede/po/zh_Hans/daede.po @@ -0,0 +1,446 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Project-Id-Version: PACKAGE VERSION\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: zh-Hans\n" +"MIME-Version: 1.0\n" +"Content-Transfer-Encoding: 8bit\n" + +# ---- config.js: Service Status ---- +msgid "Service Status" +msgstr "服务状态" + +msgid "Collecting data…" +msgstr "正在收集数据…" + +msgid "RUNNING" +msgstr "运行中" + +msgid "STOPPED" +msgstr "已停止" + +msgid "Backend" +msgstr "后端" + +msgid "Listen" +msgstr "监听" + +msgid "Toggle service" +msgstr "开关服务" + +msgid "Open WebUI" +msgstr "打开 WebUI" + +msgid "Restart" +msgstr "重启" + +msgid "Hot Reload" +msgstr "热重载" + +# ---- config.js: Backend Switcher ---- +msgid "Active backend" +msgstr "当前后端" + +msgid "Active" +msgstr "当前" + +msgid "Not installed" +msgstr "未安装" + +msgid "Switch to %s" +msgstr "切换到 %s" + +msgid "%s is not installed" +msgstr "%s 未安装" + +msgid "%s is not installed. Install it first, then switch backend." +msgstr "%s 未安装。请先安装再切换后端。" + +msgid "Switching backend stops the current service first. Click start when you want the new backend to run." +msgstr "切换后端会先停止当前服务。点击开关启动新后端即可。" + +msgid "Stopping current backend…" +msgstr "停止当前后端中…" + +msgid "Switching to %s…" +msgstr "切换至 %s 中…" + +msgid "Switched to %s. Click start when you want it to run." +msgstr "已切换至 %s。点击开关启动。" + +msgid "Backend switched to %s. Click start to run it." +msgstr "已切换至 %s。点击开关启动。" + +msgid "Switch failed." +msgstr "切换失败。" + +msgid "Failed to switch backend: %s" +msgstr "切换后端失败:%s" + +# ---- config.js: Init Actions ---- +msgid "Action \"%s\" error: %s" +msgstr "操作 \"%s\" 出错:%s" + +msgid "Action \"%s\" failed: %s" +msgstr "操作 \"%s\" 失败:%s" + +msgid "Action \"%s\" succeeded." +msgstr "操作 \"%s\" 成功。" + +msgid "Toggle failed: %s" +msgstr "开关切换失败:%s" + +msgid "%s is already running. Stop %s before starting %s because both backends share the same eBPF/cgroup attachment." +msgstr "%s 已在运行。%s 与 %s 共用 eBPF/cgroup 挂载点,请先停止前者。" + +# ---- config.js: daede Settings ---- +msgid "daede Settings" +msgstr "daede 设置" + +msgid "A modern dashboard for dae. Subscriptions, nodes, routing and DNS are managed in the daed WebUI." +msgstr "dae 的现代化控制台。订阅、节点、路由和 DNS 请在 daed WebUI 中管理。" + +msgid "Listen Address" +msgstr "监听地址" + +msgid "Host:port that the daed WebUI and GraphQL API listen on." +msgstr "daed WebUI 与 GraphQL API 监听的 Host:Port。" + +msgid "Max Log Size (MB)" +msgstr "单文件大小上限(MB)" + +msgid "Rotate the log file once it grows past this many megabytes." +msgstr "单个日志文件超过此大小后轮转。" + +msgid "Max Log Backups" +msgstr "保留旧日志数" + +msgid "Number of rotated log files to keep." +msgstr "轮转后保留的旧日志文件数。" + +msgid "Log Advanced Settings" +msgstr "日志高级设置" + +# ---- config.js: dae Settings ---- +msgid "dae Parameters" +msgstr "dae 参数" + +msgid "Configuration file path and log rotation settings for dae." +msgstr "dae 配置文件路径与日志轮转参数。" + +msgid "Configuration File" +msgstr "配置文件" + +# ---- config.js: dae Editor ---- +msgid "dae Configuration" +msgstr "dae 配置" + +msgid "Text-only mode." +msgstr "纯文本模式。" + +msgid "dae has no built-in WebUI. Edit the config DSL below by hand (subscriptions, nodes, routing, DNS). \"Initialize from example\" loads the bundled template — replace the placeholder subscription URL with your own before saving. For point-and-click management, switch backend to daed." +msgstr "dae 没有内置 WebUI,下面的 DSL 配置需要手工编辑(订阅、节点、路由、DNS)。「从示例初始化」会载入官方模板——保存前请把占位的订阅链接换成你自己的。想图形化点选管理,请把后端切回 daed。" + +msgid "dae config file is empty. Click \"Initialize from example\" to start, or paste your config here." +msgstr "dae 配置文件为空。点「从示例初始化」开始,或直接粘贴你的配置。" + +# ---- config.js: dae Parameters ---- +msgid "dae Parameters" +msgstr "dae 参数" + +msgid "Edit config DSL — subscriptions, nodes, routing, DNS. Load template via Initialize. Replace placeholder URL before saving. Switch to daed for GUI." +msgstr "编辑 dae DSL 配置——订阅、节点、路由、DNS。通过「从示例初始化」载入模板。保存前务必替换占位订阅链接。图形化管理请切换至 daed。" + +msgid "Save and Hot Reload" +msgstr "保存并热重载" + +msgid "Initialize from example" +msgstr "从示例初始化" + +msgid "+ Insert Block…" +msgstr "+ 插入块…" + +msgid "Insert config block at cursor" +msgstr "在光标处插入配置块" + +msgid "lines" +msgstr "行" + +msgid "⚠ %d placeholder" +msgstr "⚠ %d 处占位" + +msgid "⚠ 还差订阅链接才能跑:检测到 %d 行占位 URL" +msgstr "⚠ 还差订阅链接才能跑:检测到 %d 行占位 URL" + +msgid " / " +msgstr " / " + +msgid "这些 " +msgstr "这些 " + +msgid " 都是 dae 官方模板留的假地址,不是真订阅。把其中任意一行的 URL 换成你机场的订阅链接(删掉多余行),保存即可跑通。" +msgstr " 都是 dae 官方模板留的假地址,不是真订阅。把其中任意一行的 URL 换成你机场的订阅链接(删掉多余行),保存即可跑通。" + +msgid "Placeholder lines (click line number to jump):" +msgstr "占位行(点行号跳转):" + +msgid "Jump to line" +msgstr "跳转到该行" + +msgid "Validating…" +msgstr "校验中…" + +msgid "Validate failed: %s" +msgstr "配置校验失败:%s" + +msgid "Saving…" +msgstr "保存中…" + +msgid "Reload failed: %s" +msgstr "热重载失败:%s" + +msgid "Validated · saved · hot-reloaded" +msgstr "已校验 · 已保存 · 已热重载" + +msgid "Save failed: %s" +msgstr "保存失败:%s" + +msgid "Loading example…" +msgstr "加载示例中…" + +msgid "Example loaded" +msgstr "示例已加载" + +msgid "Init failed: %s" +msgstr "初始化失败:%s" + +msgid "Selected backend is not installed. Install dae or daed from the package feed first." +msgstr "当前后端未安装,请从软件源安装 dae 或 daed。" + +# ---- log.js ---- +msgid "Loading…" +msgstr "加载中…" + +msgid "Log file does not exist yet." +msgstr "日志文件尚不存在。" + +msgid "Error reading log: %s" +msgstr "读取日志出错:%s" + +msgid "Log file cleared." +msgstr "日志文件已清空。" + +msgid "Failed to clear log: %s" +msgstr "清空日志失败:%s" + +msgid "Truncate %s log on the router? This cannot be undone." +msgstr "确定要清空路由器上的 %s 日志吗?此操作不可恢复。" + +# ---- updates.js: Data Updates ---- +msgid "Settings" +msgstr "设置" + +msgid "Updates" +msgstr "更新" + +msgid "Log" +msgstr "日志" + +msgid "Data Updates" +msgstr "数据更新" + +msgid "Package Updates" +msgstr "软件包更新" + +msgid "System Health" +msgstr "系统健康" + +msgid "Update" +msgstr "更新" + +msgid "Upgrade" +msgstr "升级" + +msgid "Clean" +msgstr "清理" + +msgid "missing — click Update to fetch" +msgstr "缺失——点 Update 下载" + +msgid "%s download started — will refresh in 1–3 min." +msgstr "%s 下载已开始——1–3 分钟后刷新。" + +msgid "%s update already running." +msgstr "%s 更新已在运行。" + +msgid "%s update failed: %s" +msgstr "%s 更新失败:%s" + +msgid "%s update error: %s" +msgstr "%s 更新出错:%s" + +# ---- updates.js: Package Updates ---- +msgid "%s binary" +msgstr "%s 内核" + +msgid "active" +msgstr "当前" + +msgid "installed" +msgstr "已安装" + +msgid "latest" +msgstr "最新" + +msgid "up to date" +msgstr "已是最新" + +msgid "not installed via package manager" +msgstr "非包管理器安装" + +msgid "Unavailable" +msgstr "不可用" + +msgid "Run \"apk upgrade %s\" now? This may restart the active backend." +msgstr "立即运行 \"apk upgrade %s\"?这可能会重启当前后端。" + +msgid "%s upgrade started — see log below." +msgstr "%s 升级已开始——见下方日志。" + +msgid "%s upgrade already running." +msgstr "%s 升级已在运行。" + +msgid "%s upgrade failed: %s" +msgstr "%s 升级失败:%s" + +msgid "%s upgrade error: %s" +msgstr "%s 升级出错:%s" + +# ---- updates.js: System Health ---- +msgid "Probing…" +msgstr "检测中…" + +msgid "%s process" +msgstr "%s 进程" + +msgid "Kernel BTF" +msgstr "内核 BTF" + +msgid "WebUI/API" +msgstr "WebUI/API" + +msgid "Hot reload" +msgstr "热重载" + +msgid "netns daens" +msgstr "netns daens" + +msgid "pgrep reports the backend is running" +msgstr "pgrep 检测到后端正在运行" + +msgid "backend process is not running" +msgstr "后端进程未运行" + +msgid "service is running, expected on port %s" +msgstr "服务运行中,预期监听端口 %s" + +msgid "service is stopped" +msgstr "服务已停止" + +msgid "not available — eBPF needs CONFIG_DEBUG_INFO_BTF or vmlinux-btf" +msgstr "不可用——eBPF 需要 CONFIG_DEBUG_INFO_BTF 或 vmlinux-btf" + +msgid "/etc/init.d/dae hot_reload is available" +msgstr "dae 热重载可用" + +msgid "netns daens removed." +msgstr "netns daens 已清理。" + +msgid "exists, may block daed start" +msgstr "已存在,可能阻碍 daed 启动" + +msgid "clean" +msgstr "干净" + +# ---- log.js toolbar ---- +msgid "%s Realtime Log" +msgstr "%s 实时日志" + +msgid "Auto-scroll" +msgstr "自动滚动" + +msgid "Pause" +msgstr "暂停" + +msgid "All" +msgstr "全部" + +msgid "Node Status" +msgstr "节点状态" + +msgid "Proxy Traffic" +msgstr "代理流量" + +msgid "Clear View" +msgstr "清空显示" + +msgid "Clear File" +msgstr "清空文件" + +msgid "Download" +msgstr "下载" + +# ---- config.js: Subscription Card ---- +msgid "Subscription Management" +msgstr "订阅管理" + +msgid "Paste subscription URL here" +msgstr "在此粘贴订阅链接" + +msgid "Apply and Save" +msgstr "应用并保存" + +msgid "Applying…" +msgstr "应用…" + +msgid "Saved, start dae to apply" +msgstr "已保存,启动 dae 后生效" + +msgid "Saved and applied" +msgstr "已保存并应用" + +msgid "Saved, reload failed" +msgstr "已保存,重载失败" + +msgid "Not found, load example first" +msgstr "未找到订阅行,请先载入示例" + +msgid "Failed" +msgstr "失败" + +msgid "This will replace your current config. Continue?" +msgstr "这将覆盖当前配置,继续?" + +msgid "Example loaded — save to apply" +msgstr "示例已载入,保存后生效" + +msgid "Saved OK (start dae to apply)" +msgstr "已保存(启动 dae 后生效)" + +msgid "Configuration Editor" +msgstr "配置编辑" + +msgid "Edit config DSL — subscriptions, nodes, routing, DNS. Load template via Initialize. Replace placeholder URL before saving. Switch to daed for GUI." +msgstr "编辑 dae DSL 配置——订阅、节点、路由、DNS。通过「从示例初始化」载入模板。保存前务必替换占位订阅链接。图形化管理请切换至 daed。" + +# ---- config.js: Proxy Health ---- +msgid "Check Proxy Status" +msgstr "检测代理状态" + +msgid "OK, %d flows through proxy" +msgstr "正常,%d 条代理流量" + +msgid "No proxy traffic" +msgstr "暂无代理流量" diff --git a/luci-app-daede/root/etc/config/daede b/luci-app-daede/root/etc/config/daede new file mode 100644 index 000000000..a5b9a64c8 --- /dev/null +++ b/luci-app-daede/root/etc/config/daede @@ -0,0 +1,2 @@ +config daede 'config' + option active_backend 'daed' diff --git a/luci-app-daede/root/etc/uci-defaults/90-luci-app-daede-init b/luci-app-daede/root/etc/uci-defaults/90-luci-app-daede-init new file mode 100644 index 000000000..a66ac1a7d --- /dev/null +++ b/luci-app-daede/root/etc/uci-defaults/90-luci-app-daede-init @@ -0,0 +1,153 @@ +#!/bin/sh +# Patch the daed package's /etc/init.d/daed to clean up /run/netns/daens on +# stop_service(). Without this, restarting daed fails with: +# fatal: failed to create netns: open /run/netns/daens: file exists +# +# This script lives under /etc/uci-defaults/ which OpenWrt runs once on +# every package install/upgrade and then removes - so each time luci-app-daede +# is (re-)installed we re-patch the daed init.d even if it was overwritten +# by a daed package upgrade in between. +# +# The patch is idempotent: a marker comment guards against double-injection. + +set -e + +# luci-app-daede owns its own LuCI menu and view path. Remove stale files from +# the official luci-app-daed package and earlier daede builds that reused +# /view/daed. +rm -f /usr/share/luci/menu.d/luci-app-daed.json +rm -f /usr/share/rpcd/acl.d/luci-app-daed.json +rm -rf /usr/share/luci-app-daed +rm -rf /www/luci-static/resources/view/daed +rm -f /usr/lib/lua/luci/i18n/daed.zh-cn.lmo +rm -f /tmp/luci-indexcache.* 2>/dev/null || true +rm -rf /tmp/luci-modulecache/* 2>/dev/null || true + +if [ -f /etc/config/daed_luci ]; then + old_backend="$(uci -q get daed_luci.config.active_backend || true)" + if ! uci -q get daede.config >/dev/null 2>&1; then + touch /etc/config/daede + uci -q set daede.config=daede + case "$old_backend" in + dae|daed) uci -q set daede.config.active_backend="$old_backend" ;; + esac + uci -q commit daede + elif ! uci -q get daede.config.active_backend >/dev/null 2>&1; then + case "$old_backend" in + dae|daed) + uci -q set daede.config.active_backend="$old_backend" + uci -q commit daede + ;; + esac + fi + rm -f /etc/config/daed_luci +fi + +if ! uci -q get daede.config >/dev/null 2>&1; then + touch /etc/config/daede + uci -q set daede.config=daede + if [ -f /etc/init.d/daed ]; then + uci -q set daede.config.active_backend='daed' + else + uci -q set daede.config.active_backend='dae' + fi + uci -q commit daede +fi + +# Remove --disable-timestamp from dae init so log lines carry timestamps +DAE_INIT="/etc/init.d/dae" +if [ -f "$DAE_INIT" ]; then + sed -i 's/--disable-timestamp//' "$DAE_INIT" + sed -i '/procd_append_param command *$/d' "$DAE_INIT" +fi + +# dae expects geoip.dat / geosite.dat under /usr/share/dae/; +# v2ray-geoip installs them to /usr/share/v2ray/. Create symlinks. +if [ -f /usr/share/v2ray/geoip.dat ] && [ ! -f /usr/share/dae/geoip.dat ]; then + mkdir -p /usr/share/dae + ln -sf /usr/share/v2ray/geoip.dat /usr/share/dae/geoip.dat +fi +if [ -f /usr/share/v2ray/geosite.dat ] && [ ! -f /usr/share/dae/geosite.dat ]; then + mkdir -p /usr/share/dae + ln -sf /usr/share/v2ray/geosite.dat /usr/share/dae/geosite.dat +fi +if [ -f /usr/share/v2ray/geoip.dat ] && [ ! -f /usr/share/daed/geoip.dat ]; then + mkdir -p /usr/share/daed + ln -sf /usr/share/v2ray/geoip.dat /usr/share/daed/geoip.dat +fi +if [ -f /usr/share/v2ray/geosite.dat ] && [ ! -f /usr/share/daed/geosite.dat ]; then + mkdir -p /usr/share/daed + ln -sf /usr/share/v2ray/geosite.dat /usr/share/daed/geosite.dat +fi + +# Seed default dae form sections (group/routing/dns) for the LuCI editor. +# Idempotent: only add what's missing. Lives in the `dae` UCI package, +# consumed by gen-dae-config.sh. +if [ -f /etc/config/dae ]; then + if ! uci -q show dae | grep -q "=group$"; then + g="$(uci add dae group)" + uci -q set "dae.$g.name=proxy" + uci -q set "dae.$g.policy=min_moving_avg" + fi + if ! uci -q get dae.routing >/dev/null 2>&1; then + uci -q set dae.routing=routing + uci -q set dae.routing.private_direct=1 + uci -q set dae.routing.cn_direct=1 + uci -q set dae.routing.block_ads=0 + uci -q set dae.routing.fallback=proxy + fi + if ! uci -q get dae.dns >/dev/null 2>&1; then + uci -q set dae.dns=dns + uci -q set dae.dns.cn_upstream='udp://dns.alidns.com:53' + uci -q set dae.dns.fallback_upstream='tcp+udp://dns.google:53' + fi + # Migrate legacy group filters to the unified source / name_filter fields, + # then drop the old keys so the form shows real, editable values (the old + # fields used to re-populate the new ones and looked un-deletable). + # `uci get` joins a list into one space-separated line; the unquoted $() word- + # splits it back into items. dae tags/keywords are whitespace-free, so this is + # the intended split (do NOT set IFS=newline here — it would merge the items). + i=0 + while uci -q get "dae.@group[$i]" >/dev/null 2>&1; do + if [ -z "$(uci -q get dae.@group[$i].source)" ]; then + for v in $(uci -q get dae.@group[$i].filter_sub 2>/dev/null); do + uci -q add_list dae.@group[$i].source="$v" + done + fi + if [ -z "$(uci -q get dae.@group[$i].name_filter)" ]; then + for v in $(uci -q get dae.@group[$i].filter_node 2>/dev/null); do + uci -q add_list dae.@group[$i].name_filter="$v" + done + fi + uci -q delete dae.@group[$i].filter_sub 2>/dev/null + uci -q delete dae.@group[$i].filter_node 2>/dev/null + i=$((i+1)) + done + uci -q commit dae +fi + +INIT="/etc/init.d/daed" +MARKER="# luci-app-daede: netns-cleanup" + +[ -f "$INIT" ] || exit 0 +grep -q "$MARKER" "$INIT" && exit 0 + +# Inject `ip netns del daens 2>/dev/null || true` right after `rm -f "$LOG"` +# inside stop_service(). awk is more reliable than sed for multiline contexts. +awk -v marker="$MARKER" ' + BEGIN { in_stop = 0; injected = 0 } + /^stop_service\(\)/ { in_stop = 1 } + in_stop && /^}/ { in_stop = 0 } + { print } + in_stop && !injected && /rm -f "\$LOG"/ { + print "\tip netns del daens 2>/dev/null || true " marker + injected = 1 + } +' "$INIT" > "${INIT}.new" && mv "${INIT}.new" "$INIT" +chmod +x "$INIT" + +exit 0 + +reload_service() { + restart +} diff --git a/luci-app-daede/root/usr/share/luci-app-daede/gen-dae-config.sh b/luci-app-daede/root/usr/share/luci-app-daede/gen-dae-config.sh new file mode 100755 index 000000000..4d79ad80f --- /dev/null +++ b/luci-app-daede/root/usr/share/luci-app-daede/gen-dae-config.sh @@ -0,0 +1,368 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +# +# gen-dae-config.sh - build /etc/dae/config.dae from the dae UCI form sections, +# validate with `dae validate`, then overwrite + hot reload only on success. +# +# generate read UCI -> render config.dae -> validate -> overwrite + hot reload +# import parse existing config.dae subscription{}/node{} -> fill UCI form +# +# UCI model (package dae): +# config subscription tag/url, enabled +# config node tag/link, enabled +# config group name/policy, list source, list name_filter +# (old list filter_sub/filter_node still read as fallback) +# config routing 'routing' private_direct/cn_direct/block_ads/fallback, list custom +# config dns 'dns' cn_upstream/fallback_upstream + +. /lib/functions.sh + +CONFIG_DAE="/etc/dae/config.dae" +TMP_GEN="/tmp/dae-gen.dae" +DAE_BIN="/usr/bin/dae" +DAE_INITD="/etc/init.d/dae" + +# dae single-quoted strings have no escaping; strip quotes/newlines defensively. +sanitize() { + printf '%s' "$1" | tr -d "'" | tr -d '\n\r' +} + +# ===================== generate ===================== + +SUB_BUF="" +NODE_BUF="" +GROUP_BUF="" +FIRST_GROUP="" + +emit_sub() { + local s="$1" tag url enabled + config_get tag "$s" tag "" + config_get url "$s" url "" + config_get_bool enabled "$s" enabled 1 + [ "$enabled" = "1" ] || return 0 + url="$(sanitize "$url")" + [ -n "$url" ] || return 0 + tag="$(sanitize "$tag")" + if [ -n "$tag" ]; then + SUB_BUF="${SUB_BUF} ${tag}: '${url}' +" + else + SUB_BUF="${SUB_BUF} '${url}' +" + fi +} + +emit_node() { + local s="$1" tag link enabled + config_get tag "$s" tag "" + config_get link "$s" link "" + config_get_bool enabled "$s" enabled 1 + [ "$enabled" = "1" ] || return 0 + link="$(sanitize "$link")" + [ -n "$link" ] || return 0 + tag="$(sanitize "$tag")" + if [ -n "$tag" ]; then + NODE_BUF="${NODE_BUF} ${tag}: '${link}' +" + else + NODE_BUF="${NODE_BUF} '${link}' +" + fi +} + +emit_group() { + local s="$1" name policy + config_get name "$s" name "" + config_get policy "$s" policy "min_moving_avg" + name="$(sanitize "$name")" + [ -n "$name" ] || return 0 + [ -n "$FIRST_GROUP" ] || FIRST_GROUP="$name" + + GROUP_BUF="${GROUP_BUF} ${name} { +" + # Build the source: 'source' entries that match a subscription tag become + # subtag(), the rest (node tags / keywords) become name(). Old filter_sub / + # filter_node configs still work as a fallback when 'source' is unset. + SUBS_ACC=""; NODES_ACC=""; KW_ACC="" + # source: new field, else old filter_sub (decoupled from the name filter so + # editing one never silently drops the other) + config_list_foreach "$s" source _collect_source + [ -z "$SUBS_ACC" ] && [ -z "$NODES_ACC" ] && config_list_foreach "$s" filter_sub _collect_source + # optional name filter narrows the subscription pool only (AND); hand-picked + # nodes are explicit choices and stay in unconditionally + config_list_foreach "$s" name_filter _collect_kw + [ -z "$KW_ACC" ] && config_list_foreach "$s" filter_node _collect_kw + local andclause="" + [ -n "$KW_ACC" ] && andclause=" && name(${KW_ACC})" + + local emitted=0 + # multiple filter lines = OR, so subtag and name lines union the sources + if [ -n "$SUBS_ACC" ]; then + GROUP_BUF="${GROUP_BUF} filter: subtag(${SUBS_ACC})${andclause} +" + emitted=1 + fi + if [ -n "$NODES_ACC" ]; then + GROUP_BUF="${GROUP_BUF} filter: name(${NODES_ACC}) +" + emitted=1 + fi + # name filter with no source = filter the whole pool by name + if [ "$emitted" = 0 ] && [ -n "$KW_ACC" ]; then + GROUP_BUF="${GROUP_BUF} filter: name(${KW_ACC}) +" + fi + GROUP_BUF="${GROUP_BUF} policy: ${policy} + } +" + SUBS_ACC=""; NODES_ACC=""; KW_ACC="" +} + +SUBS_ACC="" +NODES_ACC="" +KW_ACC="" +# Subscription tags, space-padded for membership tests (" tag " match). +SUB_TAGS="" +collect_subtag() { + local t; config_get t "$1" tag "" + t="$(sanitize "$t")" + [ -n "$t" ] && SUB_TAGS="${SUB_TAGS} ${t} " +} +is_subtag() { + case "$SUB_TAGS" in *" $1 "*) return 0 ;; *) return 1 ;; esac +} +# dae filter args are bare only for plain identifiers; anything with regex +# chars / CJK / spaces must be wrapped as regex: '...' or the lexer chokes. +filter_arg() { + case "$1" in + *[!A-Za-z0-9_]*) printf "regex: '%s'" "$1" ;; + *) printf '%s' "$1" ;; + esac +} +# name-filter args do substring matching: regex metachars -> regex:, else keyword:. +# Intentionally stricter than filter_arg: a plain CJK keyword here stays a +# substring `keyword:` match, whereas filter_arg wraps any non-word char as regex. +kw_arg() { + if printf '%s' "$1" | grep -q '[][|().*+?^$\\]'; then + printf "regex: '%s'" "$1" + else + printf "keyword: '%s'" "$1" + fi +} +# classify a source entry: subscription tag -> subtag bucket, else name bucket +_collect_source() { + local v="$(sanitize "$1")" + [ -n "$v" ] || return 0 + if is_subtag "$v"; then + SUBS_ACC="${SUBS_ACC:+$SUBS_ACC, }$(filter_arg "$v")" + else + NODES_ACC="${NODES_ACC:+$NODES_ACC, }$(filter_arg "$v")" + fi +} +_collect_kw() { + local v="$(sanitize "$1")" + [ -n "$v" ] || return 0 + KW_ACC="${KW_ACC:+$KW_ACC, }$(kw_arg "$v")" +} + +generate() { + config_load dae + + # global overridable knobs (fall back to defaults) + local dial_mode log_level wan_interface lan_interface + config_get dial_mode config dial_mode "domain" + config_get log_level config log_level "info" + config_get wan_interface config wan_interface "auto" + config_get lan_interface config lan_interface "" + + SUB_BUF=""; NODE_BUF=""; GROUP_BUF=""; FIRST_GROUP="" + SUB_TAGS="" + config_foreach collect_subtag subscription + config_foreach emit_sub subscription + config_foreach emit_node node + config_foreach emit_group group + + # fall back to a default group when none defined + if [ -z "$GROUP_BUF" ]; then + GROUP_BUF=" proxy { + policy: min_moving_avg + } +" + FIRST_GROUP="proxy" + fi + + # routing / dns singletons + local private_direct cn_direct block_ads fallback cn_up fb_up + config_get_bool private_direct routing private_direct 1 + config_get_bool cn_direct routing cn_direct 1 + config_get_bool block_ads routing block_ads 0 + config_get fallback routing fallback "$FIRST_GROUP" + [ -n "$fallback" ] || fallback="$FIRST_GROUP" + fallback="$(sanitize "$fallback")" + config_get cn_up dns cn_upstream "udp://dns.alidns.com:53" + config_get fb_up dns fallback_upstream "tcp+udp://dns.google:53" + cn_up="$(sanitize "$cn_up")" + fb_up="$(sanitize "$fb_up")" + + { + echo "# Auto-generated by luci-app-daede form. Manual edits are overwritten on next save." + echo "global {" + echo " tproxy_port: 12345" + echo " tproxy_port_protect: true" + echo " log_level: ${log_level}" + [ -n "$lan_interface" ] && echo " lan_interface: ${lan_interface}" + echo " wan_interface: ${wan_interface}" + echo " auto_config_kernel_parameter: true" + echo " dial_mode: ${dial_mode}" + echo " tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1,2606:4700:4700::1111'" + echo " udp_check_dns: 'dns.google:53,8.8.8.8,2001:4860:4860::8888'" + echo " check_interval: 30s" + echo " check_tolerance: 50ms" + echo "}" + echo "" + + if [ -n "$SUB_BUF" ]; then + echo "subscription {" + printf '%s' "$SUB_BUF" + echo "}" + echo "" + fi + + if [ -n "$NODE_BUF" ]; then + echo "node {" + printf '%s' "$NODE_BUF" + echo "}" + echo "" + fi + + echo "dns {" + echo " ipversion_prefer: 4" + echo " upstream {" + echo " cndns: '${cn_up}'" + echo " fallbackdns: '${fb_up}'" + echo " }" + echo " routing {" + echo " request {" + echo " qname(geosite:cn) -> cndns" + echo " fallback: fallbackdns" + echo " }" + echo " }" + echo "}" + echo "" + + echo "group {" + printf '%s' "$GROUP_BUF" + echo "}" + echo "" + + echo "routing {" + echo " pname(NetworkManager) -> direct" + # multicast / broadcast direct (geoip:private doesn't cover these) + echo " dip(224.0.0.0/3) -> direct" + echo " dip(255.255.255.255/32) -> direct" + echo " dip('ff00::/8') -> direct" + [ "$private_direct" = "1" ] && echo " dip(geoip:private) -> direct" + # NTP time sync direct + echo " l4proto(udp) && dport(123) -> direct" + # OS connectivity checks direct (avoid captive-portal false positives) + echo " domain(connectivitycheck.gstatic.com) -> direct" + echo " domain(msftconnecttest.com) -> direct" + if [ "$cn_direct" = "1" ]; then + echo " dip(geoip:cn) -> direct" + echo " domain(geosite:cn) -> direct" + fi + [ "$block_ads" = "1" ] && echo " domain(geosite:category-ads-all) -> block" + config_list_foreach routing custom _emit_custom + echo " fallback: ${fallback}" + echo "}" + } > "$TMP_GEN" + chmod 600 "$TMP_GEN" # dae rejects world/group-accessible config files + + # overwrite live config only if validation passes + local out + out="$("$DAE_BIN" validate -c "$TMP_GEN" 2>&1)" + if [ $? -ne 0 ]; then + echo "validate failed: $(echo "$out" | head -1)" >&2 + rm -f "$TMP_GEN" + return 1 + fi + + mkdir -p /etc/dae + cat "$TMP_GEN" > "$CONFIG_DAE" + chmod 600 "$CONFIG_DAE" + rm -f "$TMP_GEN" + + if /usr/bin/pgrep -x dae >/dev/null 2>&1; then + "$DAE_INITD" hot_reload >/dev/null 2>&1 + fi + echo "ok" + return 0 +} + +_emit_custom() { + local line="$(printf '%s' "$1" | tr -d '\n\r')" + [ -n "$line" ] || return 0 + echo " ${line}" +} + +# ===================== import ===================== +# Fill the UCI form from an existing config.dae subscription{}/node{} block. +# Idempotent: wipe same-type sections first, then rebuild from file. + +import_block() { + # $1=block name (subscription|node) $2=uci section type $3=link/url field + local block="$1" stype="$2" field="$3" + awk -v blk="$block" ' + $0 ~ "^[[:space:]]*"blk"[[:space:]]*\\{" { inb=1; next } + inb && /^[[:space:]]*\}/ { inb=0 } + inb { print } + ' "$CONFIG_DAE" 2>/dev/null | while IFS= read -r raw; do + # drop comment, trim + line="$(printf '%s' "$raw" | sed 's/#.*$//' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" + [ -n "$line" ] || continue + tag=""; val="" + case "$line" in + \'*|\"*) + # starts with a quote -> untagged link + val="$line" + ;; + [A-Za-z0-9_]*:*) + # identifier ':' link -> tagged (split on first colon) + tag="$(printf '%s' "$line" | sed "s/[[:space:]]*:.*//")" + val="$(printf '%s' "$line" | sed "s/^[^:]*:[[:space:]]*//")" + ;; + *) + val="$line" + ;; + esac + # strip surrounding quotes + val="$(printf '%s' "$val" | sed "s/^['\"]//; s/['\"]$//")" + [ -n "$val" ] || continue + # skip dae example placeholders (ss://LINK, server-ip:port, localhost:1080 ...) + case "$val" in + *://LINK|*LINK*|*server-ip*|*://localhost:*|*example.com*) continue ;; + esac + sid="$(uci add dae "$stype")" + [ -n "$tag" ] && uci set "dae.$sid.tag=$tag" + uci set "dae.$sid.$field=$val" + uci set "dae.$sid.enabled=1" + done +} + +do_import() { + [ -f "$CONFIG_DAE" ] || { echo "no config.dae" >&2; return 1; } + while uci -q delete dae.@subscription[0] >/dev/null 2>&1; do :; done + while uci -q delete dae.@node[0] >/dev/null 2>&1; do :; done + import_block subscription subscription url + import_block node node link + uci commit dae + echo "ok" +} + +# ===================== entry ===================== + +case "$1" in + generate) generate ;; + import) do_import ;; + *) echo "usage: $0 {generate|import}" >&2; exit 2 ;; +esac diff --git a/luci-app-daede/root/usr/share/luci-app-daede/pkg-info.sh b/luci-app-daede/root/usr/share/luci-app-daede/pkg-info.sh new file mode 100755 index 000000000..fc791d209 --- /dev/null +++ b/luci-app-daede/root/usr/share/luci-app-daede/pkg-info.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# pkg-info.sh +# Prints "\t" for the named package, where either field is +# empty when unknown. Used by the Updates view to decide whether to enable +# the [Upgrade] button. + +PKG="$1" +case "$PKG" in + dae|daed|luci-app-daede) ;; + *) echo "" ; exit 64 ;; +esac + +installed="" +latest="" + +if command -v apk >/dev/null 2>&1; then + if apk info -e "$PKG" >/dev/null 2>&1; then + installed=$(apk list -I "$PKG" 2>/dev/null | awk -v p="$PKG" ' + $1 ~ "^" p "-" { + sub("^" p "-", "", $1); + print $1; + exit + } + ') + fi + latest=$(apk search "^${PKG}\$" 2>/dev/null | sort -V | tail -1 | sed "s/^${PKG}-//") +elif command -v opkg >/dev/null 2>&1; then + installed=$(opkg status "$PKG" 2>/dev/null | awk -F': ' '$1=="Version"{print $2; exit}') + latest=$(opkg info "$PKG" 2>/dev/null | awk -F': ' '$1=="Version"{print $2; exit}') +fi + +printf '%s\t%s\n' "$installed" "$latest" diff --git a/luci-app-daede/root/usr/share/luci-app-daede/proxy-check.sh b/luci-app-daede/root/usr/share/luci-app-daede/proxy-check.sh new file mode 100755 index 000000000..7774a64ce --- /dev/null +++ b/luci-app-daede/root/usr/share/luci-app-daede/proxy-check.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# proxy-check.sh - test whether dae's transparent proxy can actually reach a +# blocked site (default YouTube). dae proxies the router's own WAN-bound traffic +# (wan_interface), so a plain request from here is routed through dae just like a +# LAN client would be. Prints one line: ok=<0|1> code= ms= + +URL="${1:-https://www.youtube.com/generate_204}" + +code=000 +ms=0 + +if command -v curl >/dev/null 2>&1; then + out="$(curl -4 -sL --max-time 8 --connect-timeout 4 \ + -o /dev/null -w '%{http_code} %{time_total}' "$URL" 2>/dev/null)" + code="$(printf '%s' "$out" | awk '{print $1}')" + t="$(printf '%s' "$out" | awk '{print $2}')" + [ -n "$code" ] || code=000 + ms="$(awk -v x="${t:-0}" 'BEGIN{printf "%d", x*1000}')" +else + # busybox wget has no status/timing output — success is exit 0 + if wget -q -T 8 -O /dev/null "$URL" 2>/dev/null; then code=204; else code=000; fi +fi + +ok=0 +case "$code" in 200|204|301|302) ok=1 ;; esac + +echo "ok=$ok code=$code ms=$ms" diff --git a/luci-app-daede/root/usr/share/luci-app-daede/update-geo.sh b/luci-app-daede/root/usr/share/luci-app-daede/update-geo.sh new file mode 100755 index 000000000..96696f3be --- /dev/null +++ b/luci-app-daede/root/usr/share/luci-app-daede/update-geo.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# update-geo.sh +# Fork-and-detach so the LuCI RPC call returns immediately. The diagnostics +# card polls the .dat file's mtime/size to detect completion. A lock file +# in /tmp prevents concurrent runs. + +TYPE="$1" +case "$TYPE" in + geoip) + URL="https://github.com/v2fly/geoip/releases/latest/download/geoip.dat" + DEST="/usr/share/v2ray/geoip.dat" + ;; + geosite) + URL="https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat" + DEST="/usr/share/v2ray/geosite.dat" + ;; + *) + echo "usage: $0 " >&2 + exit 64 + ;; +esac + +LOCK="/tmp/luci-app-daede.${TYPE}.lock" +LOG="/tmp/luci-app-daede.${TYPE}.log" + +if [ -f "$LOCK" ]; then + # Lock is fresh (< 5 min)? Refuse. Stale? Remove and proceed. + mtime=$(date -r "$LOCK" +%s 2>/dev/null || echo 0) + age=$(( $(date +%s) - mtime )) + if [ "$age" -lt 300 ]; then + echo "${TYPE} update already in progress (PID $(cat "$LOCK" 2>/dev/null), age ${age}s)" >&2 + exit 75 + fi + rm -f "$LOCK" +fi + +if ! ( set -C; echo "$$" >"$LOCK" ) 2>/dev/null; then + echo "${TYPE} update already in progress" >&2 + exit 75 +fi + +# Spawn detached worker — parent returns immediately to LuCI. +( + exec >"$LOG" 2>&1 + trap 'rm -f "$LOCK"' EXIT INT TERM + + TMP="${DEST}.new" + mkdir -p "$(dirname "$DEST")" + echo "$(date '+%F %T') begin: $URL" + + if ! curl -fsSL --connect-timeout 15 --max-time 240 -o "$TMP" "$URL"; then + echo "$(date '+%F %T') download failed" + rm -f "$TMP" + exit 1 + fi + + size=$(wc -c < "$TMP" 2>/dev/null || echo 0) + if [ "$size" -lt 102400 ]; then + echo "$(date '+%F %T') file too small ($size bytes)" + rm -f "$TMP" + exit 2 + fi + + mv "$TMP" "$DEST" + echo "$(date '+%F %T') updated $DEST ($size bytes)" +) /dev/null 2>&1 & + +echo "started in background, see $LOG" +exit 0 diff --git a/luci-app-daede/root/usr/share/luci-app-daede/update-pkg.sh b/luci-app-daede/root/usr/share/luci-app-daede/update-pkg.sh new file mode 100755 index 000000000..920143abf --- /dev/null +++ b/luci-app-daede/root/usr/share/luci-app-daede/update-pkg.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# update-pkg.sh +# Refresh package indexes and upgrade the named package via apk (25.12+) or +# opkg (24.10). Forks the work to background so the LuCI RPC call returns +# immediately; the result is streamed to /tmp/luci-app-daede.pkg..log. + +PKG="$1" +case "$PKG" in + dae|daed|luci-app-daede) ;; + *) + echo "usage: $0 " >&2 + exit 64 + ;; +esac + +LOCK="/tmp/luci-app-daede.pkg-${PKG}.lock" +LOG="/tmp/luci-app-daede.pkg-${PKG}.log" + +if [ -f "$LOCK" ]; then + mtime=$(date -r "$LOCK" +%s 2>/dev/null || echo 0) + age=$(( $(date +%s) - mtime )) + if [ "$age" -lt 300 ]; then + echo "${PKG} update already in progress (PID $(cat "$LOCK" 2>/dev/null), age ${age}s)" >&2 + exit 75 + fi + rm -f "$LOCK" +fi + +if ! ( set -C; echo "$$" >"$LOCK" ) 2>/dev/null; then + echo "${PKG} update already in progress" >&2 + exit 75 +fi + +( + exec >"$LOG" 2>&1 + trap 'rm -f "$LOCK"' EXIT INT TERM + + echo "$(date '+%F %T') begin upgrade: $PKG" + + if command -v apk >/dev/null 2>&1; then + echo "--- apk update ---" + apk update 2>&1 + echo "--- apk upgrade $PKG ---" + # --no-self-upgrade so apk itself does not jump versions mid-op + apk upgrade --no-self-upgrade "$PKG" 2>&1 + rc=$? + elif command -v opkg >/dev/null 2>&1; then + echo "--- opkg update ---" + opkg update 2>&1 + echo "--- opkg upgrade $PKG ---" + opkg upgrade "$PKG" 2>&1 + rc=$? + else + echo "no package manager found" + exit 3 + fi + + echo "$(date '+%F %T') done (rc=$rc)" + + # luci-app-daede upgrade replaces ACL JSON — reload rpcd so changes apply. + if [ "$PKG" = "luci-app-daede" ] && [ "$rc" = "0" ]; then + echo "reloading rpcd to pick up new ACL" + /etc/init.d/rpcd reload 2>&1 + fi +) /dev/null 2>&1 & + +echo "started in background, see $LOG" +exit 0 diff --git a/luci-app-daede/root/usr/share/luci/menu.d/luci-app-daede.json b/luci-app-daede/root/usr/share/luci/menu.d/luci-app-daede.json new file mode 100644 index 000000000..4054a474c --- /dev/null +++ b/luci-app-daede/root/usr/share/luci/menu.d/luci-app-daede.json @@ -0,0 +1,36 @@ +{ + "admin/services/daede": { + "title": "daede", + "order": 20, + "action": { + "type": "firstchild" + }, + "depends": { + "acl": [ "luci-app-daede" ] + } + }, + "admin/services/daede/config": { + "title": "Settings", + "order": 10, + "action": { + "type": "view", + "path": "daede/config" + } + }, + "admin/services/daede/updates": { + "title": "Updates", + "order": 20, + "action": { + "type": "view", + "path": "daede/updates" + } + }, + "admin/services/daede/log": { + "title": "Log", + "order": 30, + "action": { + "type": "view", + "path": "daede/log" + } + } +} diff --git a/luci-app-daede/root/usr/share/rpcd/acl.d/luci-app-daede.json b/luci-app-daede/root/usr/share/rpcd/acl.d/luci-app-daede.json new file mode 100644 index 000000000..ba0ecabff --- /dev/null +++ b/luci-app-daede/root/usr/share/rpcd/acl.d/luci-app-daede.json @@ -0,0 +1,74 @@ +{ + "luci-app-daede": { + "description": "Grant access to dae/daed configuration", + "read": { + "file": { + "/etc/init.d/dae": [ "read" ], + "/etc/init.d/daed": [ "read" ], + "/etc/config/daede": [ "read" ], + "/etc/dae/config.dae": [ "read" ], + "/etc/dae/example.dae": [ "read" ], + "/var/log/dae/dae.log": [ "read" ], + "/var/log/daed/daed.log": [ "read" ], + "/usr/share/v2ray/geoip.dat": [ "read" ], + "/usr/share/v2ray/geosite.dat": [ "read" ], + "/sys/kernel/btf/vmlinux": [ "read" ], + "/run/netns/daens": [ "read" ], + "/tmp/luci-app-daede.geoip.log": [ "read" ], + "/tmp/luci-app-daede.geosite.log": [ "read" ], + "/tmp/luci-app-daede.pkg-dae.log": [ "read" ], + "/tmp/luci-app-daede.pkg-daed.log": [ "read" ], + "/tmp/luci-app-daede.pkg-luci-app-daede.log": [ "read" ], + "/usr/bin/pgrep -x dae": [ "exec" ], + "/usr/bin/pgrep -x daed": [ "exec" ], + "/usr/bin/dae validate -c /tmp/dae-validate.dae": [ "exec" ], + "/usr/bin/dae --version": [ "exec" ], + "/bin/rm -f /tmp/dae-validate.dae": [ "exec" ], + "/usr/share/luci-app-daede/pkg-info.sh dae": [ "exec" ], + "/usr/share/luci-app-daede/pkg-info.sh daed": [ "exec" ], + "/usr/share/luci-app-daede/pkg-info.sh luci-app-daede": [ "exec" ], + "/usr/share/luci-app-daede/proxy-check.sh": [ "exec" ] + }, + "ubus": { + "service": [ "list" ] + }, + "uci": [ "dae", "daed", "daede" ] + }, + "write": { + "file": { + "/etc/dae/config.dae": [ "write" ], + "/tmp/dae-validate.dae": [ "write" ], + "/etc/config/daede": [ "write" ], + "/var/log/dae/dae.log": [ "write" ], + "/var/log/daed/daed.log": [ "write" ], + "/etc/init.d/dae start": [ "exec" ], + "/etc/init.d/dae stop": [ "exec" ], + "/etc/init.d/dae restart": [ "exec" ], + "/etc/init.d/dae enable": [ "exec" ], + "/etc/init.d/dae disable": [ "exec" ], + "/etc/init.d/dae hot_reload": [ "exec" ], + "/etc/init.d/daed start": [ "exec" ], + "/etc/init.d/daed stop": [ "exec" ], + "/etc/init.d/daed restart": [ "exec" ], + "/etc/init.d/daed enable": [ "exec" ], + "/etc/init.d/daed disable": [ "exec" ], + "/sbin/uci set dae.config.enabled=*": [ "exec" ], + "/sbin/uci set daed.config.enabled=*": [ "exec" ], + "/sbin/uci set daede.config=daede": [ "exec" ], + "/sbin/uci set daede.config.active_backend=*": [ "exec" ], + "/sbin/uci commit dae": [ "exec" ], + "/sbin/uci commit daed": [ "exec" ], + "/sbin/uci commit daede": [ "exec" ], + "/sbin/ip netns del daens": [ "exec" ], + "/usr/share/luci-app-daede/update-geo.sh geoip": [ "exec" ], + "/usr/share/luci-app-daede/update-geo.sh geosite": [ "exec" ], + "/usr/share/luci-app-daede/update-pkg.sh dae": [ "exec" ], + "/usr/share/luci-app-daede/update-pkg.sh daed": [ "exec" ], + "/usr/share/luci-app-daede/update-pkg.sh luci-app-daede": [ "exec" ], + "/usr/share/luci-app-daede/gen-dae-config.sh generate": [ "exec" ], + "/usr/share/luci-app-daede/gen-dae-config.sh import": [ "exec" ] + }, + "uci": [ "dae", "daed", "daede" ] + } + } +} diff --git a/natflow/Makefile b/natflow/Makefile index fb9359094..2f854ece3 100644 --- a/natflow/Makefile +++ b/natflow/Makefile @@ -10,12 +10,12 @@ include $(INCLUDE_DIR)/kernel.mk PKG_NAME:=natflow PKG_VERSION:=20260531 -PKG_RELEASE:=11 +PKG_RELEASE:=12 PKG_SOURCE:=$(PKG_VERSION).tar.xz PKG_SOURCE_URL:=https://github.com/ptpt52/natflow.git PKG_SOURCE_PROTO:=git -PKG_SOURCE_VERSION:=881417cc21602eb10504e1ba99068b28a114b858 +PKG_SOURCE_VERSION:=4b0712931b2c14556b3ac4b6aaf921aeda07846c PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_MAINTAINER:=Chen Minqiang PKG_LICENSE:=GPL-2.0