diff --git a/luci-app-daede/Makefile b/luci-app-daede/Makefile
index 30874b9a..ac7938dd 100644
--- a/luci-app-daede/Makefile
+++ b/luci-app-daede/Makefile
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-daede
-PKG_VERSION:=1.5
-PKG_RELEASE:=1
+PKG_VERSION:=1.7
+PKG_RELEASE:=2
PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
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
index a958681c..debdf36d 100644
--- a/luci-app-daede/htdocs/luci-static/resources/view/daede/config.js
+++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/config.js
@@ -1,1102 +1,13 @@
// 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;
- })()
- ]);
-}
+'require view.daede.styles as styles';
+'require view.daede.widgets as widgets';
+'require view.daede.dae as daeView';
+'require view.daede.daed as daedView';
return view.extend({
load: function() {
@@ -1110,19 +21,19 @@ return view.extend({
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)
+ E('style', {}, styles.CSS),
+ widgets.renderStatusCard(ctx, listenAddr),
+ widgets.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());
+ children.push(daeView.renderDaeImportBanner());
+ children.push(daeView.renderDaeForms(ctx));
+ children.push(daeView.renderDaeEditor());
} else {
- children.push(renderDaedSettings());
+ children.push(daedView.renderDaedSettings());
}
return Promise.all(children.map(function(child) {
diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/dae.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/dae.js
new file mode 100644
index 00000000..0a22772a
--- /dev/null
+++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/dae.js
@@ -0,0 +1,656 @@
+// SPDX-License-Identifier: Apache-2.0
+
+'use strict';
+'require baseclass';
+'require form';
+'require fs';
+'require uci';
+'require ui';
+'require view.daede.backend as backend';
+
+/* 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'));
+ const upMsg = E('span', { 'class': 'dd-meta', 'style': 'margin-left:8px;display:none' }, '');
+ function setUpMsg(text, kind) {
+ upMsg.textContent = text;
+ upMsg.style.display = '';
+ upMsg.style.color = (kind === 'err') ? 'var(--error-color, #d33)' : (kind === 'ok') ? 'var(--success-color, #2a8)' : '';
+ if (kind !== 'err')
+ setTimeout(function() { if (upMsg.textContent === text) upMsg.style.display = 'none'; }, 5000);
+ }
+ if (fetchable) {
+ btn.addEventListener('click', function(ev) {
+ ev.preventDefault();
+ btn.disabled = true;
+ setUpMsg(_('Updating…'));
+ backend.detectRunning().then(function(r) {
+ if (!r || !r.dae)
+ return setUpMsg(_('dae is stopped; it fetches subscriptions on start.'), 'warn');
+ return fs.exec(backend.BACKENDS.dae.initd, ['hot_reload']).then(function(res) {
+ if (res && res.code !== 0)
+ setUpMsg(_('Update failed: %s').format(res.stderr || res.stdout || ('exit ' + res.code)), 'err');
+ else
+ setUpMsg(_('Subscriptions updated (dae reloaded)'), 'ok');
+ });
+ }).catch(function(e) {
+ setUpMsg(_('Update failed: %s').format(e.message || e), 'err');
+ }).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);
+ if (btn.parentNode) btn.parentNode.insertBefore(upMsg, btn.nextSibling);
+ else cell.appendChild(upMsg);
+ return cell;
+ };
+
+ /* Manual nodes (share links) */
+ s = m.section(form.GridSection, 'node', _('Nodes'),
+ _('One node share link per line — 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 = _('⚠ Still needs a subscription link: %d placeholder URL line(s) detected').format(hits.length);
+ while (howtoEl.firstChild) howtoEl.removeChild(howtoEl.firstChild);
+ /* assemble rich text from DOM nodes so the code spans keep their styling */
+ howtoEl.appendChild(document.createTextNode(_('These ')));
+ 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(_(' are placeholder addresses from dae\'s template, not real subscriptions. Replace the URL on any one line with your airport subscription link (delete the spare lines) and save.')));
+ 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 baseclass.extend({
+
+ renderDaeForms: renderDaeForms,
+
+ renderDaeImportBanner: renderDaeImportBanner,
+
+ renderDaeEditor: renderDaeEditor
+
+});
diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/daed.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/daed.js
new file mode 100644
index 00000000..4c7be810
--- /dev/null
+++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/daed.js
@@ -0,0 +1,80 @@
+// SPDX-License-Identifier: Apache-2.0
+
+'use strict';
+'require baseclass';
+'require form';
+'require uci';
+'require view.daede.widgets as widgets';
+
+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 widgets.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' }, '');
+ 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 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')
+ flash(_('Please fix the highlighted fields.'), 'err', 6000);
+ else
+ flash(_('Save failed: %s').format(e.message || e), 'err', 9000);
+ })
+ .finally(function() { save.disabled = false; });
+ });
+ card.appendChild(E('div', { 'class': 'dd-editor-actions' }, [ save, status ]));
+ return card;
+ });
+}
+
+return baseclass.extend({ renderDaedSettings: renderDaedSettings });
diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/styles.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/styles.js
new file mode 100644
index 00000000..0dfe094f
--- /dev/null
+++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/styles.js
@@ -0,0 +1,126 @@
+// SPDX-License-Identifier: Apache-2.0
+
+'use strict';
+'require baseclass';
+
+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('');
+
+return baseclass.extend({ CSS: CSS });
diff --git a/luci-app-daede/htdocs/luci-static/resources/view/daede/widgets.js b/luci-app-daede/htdocs/luci-static/resources/view/daede/widgets.js
new file mode 100644
index 00000000..0c7006ca
--- /dev/null
+++ b/luci-app-daede/htdocs/luci-static/resources/view/daede/widgets.js
@@ -0,0 +1,304 @@
+// SPDX-License-Identifier: Apache-2.0
+
+'use strict';
+'require baseclass';
+'require fs';
+'require poll';
+'require view.daede.backend as backend';
+
+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]); });
+}
+
+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 swErr = E('span', { 'class': 'dd-meta dd-err', 'style': 'display:none' }, '');
+ 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;
+ swErr.style.display = 'none';
+ 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(); })
+ .catch(function(e) {
+ /* revert the optimistic flip and show the reason inline */
+ sw.classList.toggle('is-on', !turnOn);
+ if (lbl) lbl.textContent = state.running ? 'ON' : 'OFF';
+ swErr.textContent = _('Toggle failed: %s').format(e.message || e);
+ swErr.style.display = '';
+ })
+ .finally(function() { sw.disabled = false; });
+ });
+
+ const row = E('div', { 'class': 'dd-status-row' }, [ badge ].concat(meta).concat([
+ E('span', { 'class': 'dd-grow' }),
+ swErr,
+ 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);
+ });
+}
+
+return baseclass.extend({
+
+ renderStatusCard: renderStatusCard,
+
+ renderBackendSwitcher: renderBackendSwitcher,
+
+ wrapSettingsCard: wrapSettingsCard
+
+});
diff --git a/luci-app-daede/po/templates/daede.pot b/luci-app-daede/po/templates/daede.pot
index 04b8641e..a828c4ab 100644
--- a/luci-app-daede/po/templates/daede.pot
+++ b/luci-app-daede/po/templates/daede.pot
@@ -360,3 +360,19 @@ msgstr ""
msgid "Download"
msgstr ""
+
+#: dae.js
+msgid "One node share link per line — ss, vmess, vless, trojan, tuic, hysteria2, socks5."
+msgstr ""
+
+#: dae.js
+msgid "⚠ Still needs a subscription link: %d placeholder URL line(s) detected"
+msgstr ""
+
+#: dae.js
+msgid "These "
+msgstr ""
+
+#: dae.js
+msgid " are placeholder addresses from dae's template, not real subscriptions. Replace the URL on any one line with your airport subscription link (delete the spare lines) and save."
+msgstr ""
diff --git a/luci-app-daede/po/zh-cn/daede.po b/luci-app-daede/po/zh-cn/daede.po
index 53ced613..b3bf09c3 100644
--- a/luci-app-daede/po/zh-cn/daede.po
+++ b/luci-app-daede/po/zh-cn/daede.po
@@ -651,3 +651,16 @@ msgstr "保存并应用"
msgid "Fixed link — nothing to fetch."
msgstr "固定链接,无需更新。"
+
+# ---- dae.js: i18n cleanup (was Chinese msgid) ----
+msgid "One node share link per line — ss, vmess, vless, trojan, tuic, hysteria2, socks5."
+msgstr "每行添加一个节点分享链接,支持 ss、vmess、vless、trojan、tuic、hysteria2、socks5。"
+
+msgid "⚠ Still needs a subscription link: %d placeholder URL line(s) detected"
+msgstr "⚠ 还差订阅链接才能跑:检测到 %d 行占位 URL"
+
+msgid "These "
+msgstr "这些 "
+
+msgid " are placeholder addresses from dae's template, not real subscriptions. Replace the URL on any one line with your airport subscription link (delete the spare lines) and save."
+msgstr " 都是 dae 官方模板留的假地址,不是真订阅。把其中任意一行的 URL 换成你机场的订阅链接(删掉多余行),保存即可跑通。"
diff --git a/luci-app-daede/po/zh_Hans/daede.po b/luci-app-daede/po/zh_Hans/daede.po
index bde2dd65..07833bba 100644
--- a/luci-app-daede/po/zh_Hans/daede.po
+++ b/luci-app-daede/po/zh_Hans/daede.po
@@ -444,3 +444,16 @@ msgstr "正常,%d 条代理流量"
msgid "No proxy traffic"
msgstr "暂无代理流量"
+
+# ---- dae.js: i18n cleanup (was Chinese msgid) ----
+msgid "One node share link per line — ss, vmess, vless, trojan, tuic, hysteria2, socks5."
+msgstr "每行添加一个节点分享链接,支持 ss、vmess、vless、trojan、tuic、hysteria2、socks5。"
+
+msgid "⚠ Still needs a subscription link: %d placeholder URL line(s) detected"
+msgstr "⚠ 还差订阅链接才能跑:检测到 %d 行占位 URL"
+
+msgid "These "
+msgstr "这些 "
+
+msgid " are placeholder addresses from dae's template, not real subscriptions. Replace the URL on any one line with your airport subscription link (delete the spare lines) and save."
+msgstr " 都是 dae 官方模板留的假地址,不是真订阅。把其中任意一行的 URL 换成你机场的订阅链接(删掉多余行),保存即可跑通。"
diff --git a/luci-mod-status/Makefile b/luci-mod-status/Makefile
index 14938f91..706c613c 100644
--- a/luci-mod-status/Makefile
+++ b/luci-mod-status/Makefile
@@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI Status Pages
LUCI_DEPENDS:=+luci-base +libiwinfo +rpcd-mod-iwinfo
-PKG_RELEASE:=3
+PKG_RELEASE:=4
PKG_BUILD_DEPENDS:=iwinfo
PKG_LICENSE:=Apache-2.0
diff --git a/luci-mod-status/htdocs/luci-static/resources/view/status/include/10_system.js b/luci-mod-status/htdocs/luci-static/resources/view/status/include/10_system.js
index 095a9055..d616bc5e 100644
--- a/luci-mod-status/htdocs/luci-static/resources/view/status/include/10_system.js
+++ b/luci-mod-status/htdocs/luci-static/resources/view/status/include/10_system.js
@@ -80,12 +80,7 @@ return baseclass.extend({
_('Hostname'), boardinfo.hostname,
_('Architecture'), (cpuinfo.cpuinfo || boardinfo.system) + ' ' + cpubench.cpubench,
_('Target Platform'), (L.isObject(boardinfo.release) ? boardinfo.release.target : ''),
- _('Firmware Version'), (L.isObject(boardinfo.release)
- ? '%s%s / '.format(
- boardinfo.release.description || '',
- boardinfo.release.revision ? boardinfo.release.revision : ''
- )
- : '') + (luciversion || ''),
+ _('Firmware Version'), (L.isObject(boardinfo.release) ? boardinfo.release.description + ' / ' : '') + (luciversion || ''),
_('Kernel Version'), boardinfo.kernel,
_('Local Time'), datestr,
_('Uptime'), systeminfo.uptime ? '%t'.format(systeminfo.uptime) : null,