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