💋 Sync 2026-06-12 04:33:32

This commit is contained in:
github-actions[bot] 2026-06-12 04:33:32 +08:00
parent a0d01fd5eb
commit a42ec416b0
11 changed files with 1223 additions and 1109 deletions

View File

@ -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)

View File

@ -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 <h3> 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')
]);
/* Footerdae 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
});

View File

@ -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 });

View File

@ -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 });

View File

@ -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
});

View File

@ -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 ""

View File

@ -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 换成你机场的订阅链接(删掉多余行),保存即可跑通。"

View File

@ -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 换成你机场的订阅链接(删掉多余行),保存即可跑通。"

View File

@ -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

View File

@ -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,