mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-07-27 10:31:38 +08:00
💋 Sync 2026-06-12 04:33:32
This commit is contained in:
parent
a0d01fd5eb
commit
a42ec416b0
@ -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)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
656
luci-app-daede/htdocs/luci-static/resources/view/daede/dae.js
Normal file
656
luci-app-daede/htdocs/luci-static/resources/view/daede/dae.js
Normal 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')
|
||||
]);
|
||||
|
||||
/* 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
|
||||
|
||||
});
|
||||
@ -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 });
|
||||
126
luci-app-daede/htdocs/luci-static/resources/view/daede/styles.js
Normal file
126
luci-app-daede/htdocs/luci-static/resources/view/daede/styles.js
Normal 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 });
|
||||
@ -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
|
||||
|
||||
});
|
||||
@ -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 ""
|
||||
|
||||
@ -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 换成你机场的订阅链接(删掉多余行),保存即可跑通。"
|
||||
|
||||
@ -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 换成你机场的订阅链接(删掉多余行),保存即可跑通。"
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user