mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 16:44:23 +08:00
🎨 Sync 2026-09-15 11:19:33
This commit is contained in:
1 parent
a9e0557b71
commit
ac76f4cbc9
106 files changed
+17125
-19121
No files matched your search
@@ -12,6 +12,158 @@
|
||||
'require uci';
|
||||
'require ui';
|
||||
|
||||
function pad2(value) {
|
||||
value = (value == null) ? '' : String(value).trim();
|
||||
return value.length === 1 ? '0' + value : value;
|
||||
}
|
||||
|
||||
function normalizeTimePart(value, min, max) {
|
||||
value = parseInt(String(value || '').trim(), 10);
|
||||
if (isNaN(value))
|
||||
value = min;
|
||||
value = Math.max(min, Math.min(max, value));
|
||||
return pad2(value);
|
||||
}
|
||||
|
||||
function parseCron(value) {
|
||||
const matched = String(value || '').trim().match(/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+([0-7*])$/);
|
||||
if (!matched)
|
||||
return null;
|
||||
|
||||
const minute = +matched[1],
|
||||
hour = +matched[2];
|
||||
if (minute < 0 || minute > 59 || hour < 0 || hour > 23)
|
||||
return null;
|
||||
|
||||
return {
|
||||
minute: normalizeTimePart(matched[1], 0, 59),
|
||||
hour: normalizeTimePart(matched[2], 0, 23),
|
||||
day: matched[3] === '*' ? '*' : (matched[3] === '7' ? '0' : matched[3])
|
||||
};
|
||||
}
|
||||
|
||||
function buildCron(day, hour, minute) {
|
||||
return [
|
||||
normalizeTimePart(minute, 0, 59),
|
||||
normalizeTimePart(hour, 0, 23),
|
||||
'*',
|
||||
'*',
|
||||
String(day || '*')
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function alignCronEditorRow(wrap) {
|
||||
let field = wrap?.parentNode;
|
||||
|
||||
while (field && !field.classList?.contains('cbi-value-field'))
|
||||
field = field.parentNode;
|
||||
|
||||
let row = field?.parentNode;
|
||||
while (row && !row.classList?.contains('cbi-value'))
|
||||
row = row.parentNode;
|
||||
|
||||
if (!row || !field)
|
||||
return false;
|
||||
|
||||
if (wrap._dailyRow) {
|
||||
wrap._dailyRow.style.display = row.style.display;
|
||||
return true;
|
||||
}
|
||||
|
||||
let dailyRow = E('div', { 'class': 'cbi-value homeproxy-cron-daily-row' }, [
|
||||
E('div', { 'class': 'cbi-value-title' }, _('Update time (daily)')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [ wrap._dailyField ])
|
||||
]);
|
||||
|
||||
wrap._dailyRow = dailyRow;
|
||||
row.parentNode.insertBefore(dailyRow, row.nextSibling);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function watchCronEditorRow(wrap) {
|
||||
const align = () => alignCronEditorRow(wrap);
|
||||
[ 0, 50, 200, 500, 1000, 2000 ].forEach(delay => window.setTimeout(align, delay));
|
||||
|
||||
if (typeof MutationObserver !== 'undefined' && document?.body) {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (align())
|
||||
observer.disconnect();
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
window.setTimeout(() => observer.disconnect(), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCronEditor(input) {
|
||||
if (!input)
|
||||
return null;
|
||||
|
||||
const parsed = parseCron(input.value);
|
||||
input.type = 'text';
|
||||
input.readOnly = true;
|
||||
input.tabIndex = -1;
|
||||
input.style.position = 'absolute';
|
||||
input.style.opacity = '0';
|
||||
input.style.pointerEvents = 'none';
|
||||
input.style.width = '1px';
|
||||
input.style.height = '1px';
|
||||
input.style.minWidth = '1px';
|
||||
input.style.maxWidth = '1px';
|
||||
input.style.padding = '0';
|
||||
input.style.border = '0';
|
||||
|
||||
let day = E('select', { 'class': 'cbi-input-select', 'style': 'width: 12em !important; min-width: 12em !important; max-width: 12em !important; box-sizing: border-box;' }),
|
||||
hour = E('select', { 'class': 'cbi-input-select', 'style': 'width: 4.25em !important; min-width: 4.25em !important; max-width: 4.25em !important; box-sizing: border-box;' }),
|
||||
minute = E('select', { 'class': 'cbi-input-select', 'style': 'width: 4.25em !important; min-width: 4.25em !important; max-width: 4.25em !important; box-sizing: border-box;' }),
|
||||
dailyField = E('div', { 'style': 'display: flex; align-items: center; gap: .25em; width: 9.5em;' }, [
|
||||
hour,
|
||||
E('span', ':'),
|
||||
minute
|
||||
]),
|
||||
wrap = E('div', { 'class': 'homeproxy-cron-editor' }, [ day ]);
|
||||
|
||||
wrap._dailyField = dailyField;
|
||||
|
||||
for (let i = 0; i < 24; i++)
|
||||
hour.appendChild(E('option', { value: pad2(i) }, pad2(i)));
|
||||
|
||||
for (let i = 0; i < 60; i++)
|
||||
minute.appendChild(E('option', { value: pad2(i) }, pad2(i)));
|
||||
|
||||
[
|
||||
['*', _('Every day')],
|
||||
['1', _('Every Monday')],
|
||||
['2', _('Every Tuesday')],
|
||||
['3', _('Every Wednesday')],
|
||||
['4', _('Every Thursday')],
|
||||
['5', _('Every Friday')],
|
||||
['6', _('Every Saturday')],
|
||||
['0', _('Every Sunday')]
|
||||
].forEach(([value, label]) => day.appendChild(E('option', { value }, label)));
|
||||
|
||||
day.value = parsed ? parsed.day : '*';
|
||||
hour.value = parsed ? parsed.hour : '00';
|
||||
minute.value = parsed ? parsed.minute : '00';
|
||||
input.value = buildCron(day.value, hour.value, minute.value);
|
||||
input.setAttribute('value', input.value);
|
||||
|
||||
const sync = () => {
|
||||
input.value = buildCron(day.value, hour.value, minute.value);
|
||||
input.setAttribute('value', input.value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
|
||||
day.addEventListener('change', sync);
|
||||
hour.addEventListener('change', sync);
|
||||
minute.addEventListener('change', sync);
|
||||
wrap.appendChild(input);
|
||||
window.setTimeout(() => watchCronEditorRow(wrap), 0);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
dns_strategy: {
|
||||
'': _('Default'),
|
||||
@@ -86,6 +238,29 @@ return baseclass.extend({
|
||||
}
|
||||
}),
|
||||
|
||||
CBIMultiValue: form.MultiValue.extend({
|
||||
__name__: 'CBI.HomeProxyMultiValue',
|
||||
|
||||
renderWidget(section_id, _option_index, cfgvalue) {
|
||||
let value = (cfgvalue != null) ? cfgvalue : this.default,
|
||||
choices = this.transformChoices() || {},
|
||||
widget = new ui.Dropdown(L.toArray(value), choices, {
|
||||
id: this.cbid(section_id),
|
||||
sort: this.keylist,
|
||||
multiple: true,
|
||||
optional: this.optional || this.rmempty,
|
||||
select_placeholder: this.placeholder,
|
||||
create: this.create,
|
||||
display_items: this.display_size ?? this.size ?? 3,
|
||||
dropdown_items: this.dropdown_size ?? this.size ?? -1,
|
||||
validate: this.getValidator(section_id),
|
||||
disabled: (this.readonly != null) ? this.readonly : this.map.readonly
|
||||
});
|
||||
|
||||
return widget.render();
|
||||
}
|
||||
}),
|
||||
|
||||
calcStringMD5(e) {
|
||||
/* Thanks to https://stackoverflow.com/a/41602636 */
|
||||
let h = (a, b) => {
|
||||
@@ -185,6 +360,49 @@ return baseclass.extend({
|
||||
return L.resolveDefault(callGetSingBoxFeatures(), {});
|
||||
},
|
||||
|
||||
renderCronSelector(/* ... */) {
|
||||
if (!this._homeproxyCronOriginalFormvalue) {
|
||||
this._homeproxyCronOriginalFormvalue = this.formvalue;
|
||||
this.formvalue = function(section_id) {
|
||||
let cbid = this.cbid(section_id),
|
||||
ids = [ cbid, 'widget.' + cbid ],
|
||||
roots = [ document.getElementById('modal_overlay'), document ],
|
||||
input = null;
|
||||
|
||||
for (let root of roots) {
|
||||
if (!root)
|
||||
continue;
|
||||
|
||||
for (let el of root.querySelectorAll('input')) {
|
||||
if (ids.includes(el.id) || ids.includes(el.name)) {
|
||||
input = el;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (input)
|
||||
break;
|
||||
}
|
||||
|
||||
let value = input?.value ||
|
||||
this._homeproxyCronOriginalFormvalue?.call(this, section_id) ||
|
||||
this.default ||
|
||||
'0 0 * * *';
|
||||
|
||||
return parseCron(value) ? value : (this.default || '0 0 * * *');
|
||||
};
|
||||
}
|
||||
|
||||
let node = form.Value.prototype.renderWidget.apply(this, arguments),
|
||||
editor = renderCronEditor(node.querySelector('input'));
|
||||
|
||||
return editor || node;
|
||||
},
|
||||
|
||||
renderCronSelectorRow(/* ... */) {
|
||||
return form.Value.prototype.render.apply(this, arguments);
|
||||
},
|
||||
|
||||
generateRand(type, length) {
|
||||
let byteArr;
|
||||
if (['base64', 'hex'].includes(type))
|
||||
@@ -208,6 +426,9 @@ return baseclass.extend({
|
||||
},
|
||||
|
||||
loadDefaultLabel(uciconfig, ucisection) {
|
||||
if (!ucisection)
|
||||
return '';
|
||||
|
||||
let label = uci.get(uciconfig, ucisection, 'label');
|
||||
if (label) {
|
||||
return label;
|
||||
@@ -218,73 +439,127 @@ return baseclass.extend({
|
||||
},
|
||||
|
||||
loadModalTitle(title, addtitle, uciconfig, ucisection) {
|
||||
if (!ucisection)
|
||||
return addtitle;
|
||||
|
||||
let label = uci.get(uciconfig, ucisection, 'label');
|
||||
return label ? title + ' » ' + label : addtitle;
|
||||
},
|
||||
|
||||
toArray(value) {
|
||||
if (Array.isArray(value))
|
||||
return value;
|
||||
else if (value == null || value === '')
|
||||
return [];
|
||||
|
||||
return [ value ];
|
||||
},
|
||||
|
||||
showConfigDiagnostics(diagnostics) {
|
||||
let items = diagnostics?.items || [],
|
||||
generatedAt = null,
|
||||
noticeColor = '#1f232b',
|
||||
noticeAccentColor = '#8b1e1e';
|
||||
|
||||
document.querySelectorAll('.homeproxy-config-diagnostics').forEach((node) => node.remove());
|
||||
|
||||
if (!items.length)
|
||||
return;
|
||||
|
||||
if (diagnostics?.time) {
|
||||
let date = new Date(Number(diagnostics.time) * 1000);
|
||||
if (!isNaN(date.getTime()))
|
||||
generatedAt = date.toLocaleString();
|
||||
}
|
||||
|
||||
ui.addNotification(null, E('div', { 'style': 'color:%s;'.format(noticeColor) }, [
|
||||
E('p', { 'style': 'color:%s;font-weight:600;'.format(noticeColor) }, _('HomeProxy found the following issues. Check and fix them manually:')),
|
||||
E('p', { 'style': 'color:%s;font-weight:600;'.format(noticeAccentColor) },
|
||||
generatedAt
|
||||
? _('These diagnostics are from the last configuration generation or subscription update (%s). Re-apply, restart the service, or update subscriptions again after fixing them to refresh diagnostics.').format(generatedAt)
|
||||
: _('These diagnostics are from the last configuration generation or subscription update. Re-apply, restart the service, or update subscriptions again after fixing them to refresh diagnostics.')),
|
||||
E('ul', { 'style': 'color:%s;'.format(noticeColor) }, items.map((item) => E('li', { 'style': 'color:%s;'.format(noticeColor) }, [
|
||||
E('strong', { 'style': 'color:%s;'.format(noticeColor) }, '[' + (item.type || 'warning') + '] '),
|
||||
item.message || '',
|
||||
item.suggestion ? E('div', { 'style': 'margin-top:.2em;color:%s;font-weight:600;'.format(noticeAccentColor) },
|
||||
_('Suggestion:') + item.suggestion) : ''
|
||||
])))
|
||||
]), 'warning', 'homeproxy-config-diagnostics');
|
||||
normalizeSectionId(label, prefix) {
|
||||
let section_id = (label || '').trim().replace(/[^A-Za-z0-9_]/g, '_').replace(/^_+|_+$/g, '');
|
||||
return (prefix || 'cfg') + '_' + (section_id || 'section');
|
||||
},
|
||||
|
||||
renderSectionAdd(section, extra_class) {
|
||||
let el = form.GridSection.prototype.renderSectionAdd.apply(section, [ extra_class ]),
|
||||
nameEl = el.querySelector('.cbi-section-create-name');
|
||||
ui.addValidator(nameEl, 'uciname', true, (v) => {
|
||||
let button = el.querySelector('.cbi-section-create > .cbi-button-add');
|
||||
let uciconfig = section.uciconfig || section.map.config;
|
||||
nameEl = el.querySelector('.cbi-section-create-name'),
|
||||
button = el.querySelector('.cbi-section-create > .cbi-button-add'),
|
||||
uciconfig = section.uciconfig || section.map.config,
|
||||
sectiontype = section.sectiontype,
|
||||
labelEl = E('input', {
|
||||
'type': 'text',
|
||||
'class': nameEl.className
|
||||
}),
|
||||
hintEl = E('div', { 'class': 'cbi-value-description' });
|
||||
|
||||
if (!v) {
|
||||
nameEl.style.display = 'none';
|
||||
nameEl.parentNode.insertBefore(labelEl, nameEl);
|
||||
nameEl.parentNode.appendChild(hintEl);
|
||||
button.disabled = true;
|
||||
|
||||
const syncSectionName = () => {
|
||||
let label = (labelEl.value || '').trim();
|
||||
hintEl.textContent = '';
|
||||
|
||||
if (!label) {
|
||||
nameEl.value = '';
|
||||
button.disabled = true;
|
||||
return true;
|
||||
} else if (uci.get(uciconfig, v)) {
|
||||
button.disabled = true;
|
||||
return _('Expecting: %s').format(_('unique UCI identifier'));
|
||||
} else {
|
||||
button.disabled = null;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
}, 'blur', 'keyup');
|
||||
|
||||
let duplicate = false;
|
||||
uci.sections(uciconfig, sectiontype, (res) => {
|
||||
if ((res.label || res['.name']) === label)
|
||||
duplicate = true;
|
||||
});
|
||||
|
||||
if (duplicate) {
|
||||
nameEl.value = '';
|
||||
button.disabled = true;
|
||||
hintEl.textContent = _('Expecting: %s').format(_('unique value'));
|
||||
return;
|
||||
}
|
||||
|
||||
let normalized = (label || '').trim().replace(/[^A-Za-z0-9_]/g, '_').replace(/^_+|_+$/g, '') || 'section',
|
||||
section_id = (sectiontype || 'cfg') + '_' + normalized,
|
||||
suffix = 1;
|
||||
while (uci.get(uciconfig, section_id))
|
||||
section_id = (sectiontype || 'cfg') + '_' + normalized + '_' + suffix++;
|
||||
|
||||
nameEl.value = section_id;
|
||||
nameEl.dataset.sectionId = section_id;
|
||||
nameEl.dataset.sectionLabel = label;
|
||||
button.disabled = null;
|
||||
};
|
||||
|
||||
labelEl.addEventListener('input', syncSectionName);
|
||||
labelEl.addEventListener('blur', syncSectionName);
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
syncSectionName();
|
||||
|
||||
let label = nameEl.dataset.sectionLabel,
|
||||
section_id = nameEl.dataset.sectionId;
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (label && section_id && uci.get(uciconfig, section_id))
|
||||
uci.set(uciconfig, section_id, 'label', label);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
return el;
|
||||
},
|
||||
|
||||
installCloseButtonText() {
|
||||
if (window.__homeproxyCloseButtonText)
|
||||
return;
|
||||
|
||||
window.__homeproxyCloseButtonText = true;
|
||||
|
||||
let fix = (root) => {
|
||||
for (let el of (root || document).querySelectorAll('button, .btn, input[type="button"], input[type="submit"]')) {
|
||||
let text = (el.textContent || el.value || '').trim();
|
||||
if (text !== 'Dismiss' && text !== '\u5ffd\u7565')
|
||||
continue;
|
||||
|
||||
if ('value' in el && !el.textContent.trim())
|
||||
el.value = _('Close');
|
||||
else
|
||||
el.textContent = _('Close');
|
||||
}
|
||||
};
|
||||
|
||||
let observe = () => {
|
||||
if (!document.body)
|
||||
return;
|
||||
|
||||
fix(document);
|
||||
new MutationObserver((mutations) => {
|
||||
for (let mutation of mutations)
|
||||
for (let node of mutation.addedNodes)
|
||||
if (node.nodeType === 1)
|
||||
fix(node);
|
||||
fix(document);
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
};
|
||||
|
||||
if (document.body)
|
||||
observe();
|
||||
else
|
||||
document.addEventListener('DOMContentLoaded', observe, { once: true });
|
||||
},
|
||||
|
||||
uploadCertificate(_option, type, filename, ev) {
|
||||
const callWriteCertificate = rpc.declare({
|
||||
object: 'luci.homeproxy',
|
||||
@@ -305,6 +580,44 @@ return baseclass.extend({
|
||||
.catch((e) => { ui.addNotification(null, E('p', e.message)) });
|
||||
},
|
||||
|
||||
uploadPanel(_option, ev) {
|
||||
const callInstallPanel = rpc.declare({
|
||||
object: 'luci.homeproxy',
|
||||
method: 'clash_api_install_panel',
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
const normalizeMessage = (message) => {
|
||||
switch (message) {
|
||||
case 'panel_backup_failed':
|
||||
return _('Backup panel failed.');
|
||||
case 'panel_restore_failed':
|
||||
return _('Restore panel failed.');
|
||||
case 'empty_panel_zip':
|
||||
return _('Panel ZIP file is empty.');
|
||||
case 'unzip_unavailable':
|
||||
return _('The unzip command is unavailable.');
|
||||
case 'invalid_panel_zip':
|
||||
return _('Invalid panel ZIP file.');
|
||||
case 'panel_install_failed':
|
||||
return _('Install panel failed.');
|
||||
default:
|
||||
return message || _('unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
return ui.uploadFile('/tmp/homeproxy_panel.tmp', ev.target)
|
||||
.then(L.bind((_btn, res) => {
|
||||
return L.resolveDefault(callInstallPanel(), {}).then((ret) => {
|
||||
if (ret.result === true)
|
||||
ui.addNotification(null, E('p', _('Your %s was successfully uploaded. Size: %sB.').format(_('panel ZIP package'), res.size)), 'info');
|
||||
else
|
||||
ui.addNotification(null, E('p', _('Failed to upload %s, error: %s.').format(_('panel ZIP package'), normalizeMessage(ret.error))), 'danger');
|
||||
});
|
||||
}, this, ev.target))
|
||||
.catch((e) => { ui.addNotification(null, E('p', e.message || e), 'danger') });
|
||||
},
|
||||
|
||||
validateBase64Key(length, section_id, value) {
|
||||
/* Thanks to luci-proto-wireguard */
|
||||
if (section_id && value)
|
||||
@@ -345,7 +658,7 @@ return baseclass.extend({
|
||||
if (section_id) {
|
||||
if (!value)
|
||||
return _('Expecting: %s').format(_('non-empty value'));
|
||||
if (ucioption === 'node' && ['urltest', 'selector'].includes(value))
|
||||
if (ucioption === 'node' && (value === 'urltest' || value === 'selector'))
|
||||
return true;
|
||||
|
||||
let duplicate = false;
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* HomeProxy 面板入口 helper。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
const DEFAULT_DASHBOARD_URL = 'https://metacubexd.pages.dev/#/overview';
|
||||
const DEFAULT_CONTROLLER = '127.0.0.1:9090';
|
||||
const DEFAULT_PROXY_LISTEN = '127.0.0.1:9091';
|
||||
|
||||
return baseclass.extend({
|
||||
parseController(value) {
|
||||
let result = {
|
||||
hostname: '',
|
||||
port: '',
|
||||
https: false
|
||||
};
|
||||
|
||||
if (!value)
|
||||
return result;
|
||||
|
||||
try {
|
||||
let url = new URL(value.match(/^https?:\/\//) ? value : 'http://' + value);
|
||||
result.hostname = url.hostname;
|
||||
result.port = url.port;
|
||||
result.https = url.protocol === 'https:';
|
||||
} catch(e) { }
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
deriveProxyController(value) {
|
||||
let controller = this.parseController(value || DEFAULT_CONTROLLER);
|
||||
|
||||
if (!controller.hostname)
|
||||
return DEFAULT_PROXY_LISTEN;
|
||||
|
||||
let hostname = controller.hostname.replace(/^\[(.*)\]$/, '$1');
|
||||
|
||||
return String.format('%s:9091', hostname.includes(':') ? '[' + hostname + ']' : hostname);
|
||||
},
|
||||
|
||||
configuredUrl(uci, config) {
|
||||
return uci.get(config, 'config', 'metacubexd_url') || DEFAULT_DASHBOARD_URL;
|
||||
},
|
||||
|
||||
baseUrl(uci, config) {
|
||||
let dashboard = this.configuredUrl(uci, config),
|
||||
hash = dashboard.indexOf('#');
|
||||
|
||||
if (hash >= 0)
|
||||
dashboard = dashboard.slice(0, hash);
|
||||
|
||||
return dashboard.replace(/\/+$/, '');
|
||||
},
|
||||
|
||||
setupUrl(uci, config) {
|
||||
let enabled = uci.get(config, 'config', 'clash_api_enabled') === '1',
|
||||
target = uci.get(config, 'config', 'clash_api_external_controller') || DEFAULT_CONTROLLER,
|
||||
proxy = uci.get(config, 'config', 'clash_api_proxy_external_controller') || this.deriveProxyController(target),
|
||||
controller = this.parseController(proxy),
|
||||
secret = uci.get(config, 'config', 'clash_api_secret') || '';
|
||||
|
||||
if (!enabled || !controller.hostname)
|
||||
return null;
|
||||
|
||||
let dashboard = this.configuredUrl(uci, config),
|
||||
base = this.baseUrl(uci, config);
|
||||
|
||||
if (dashboard.includes('yacd.metacubex.one'))
|
||||
return dashboard;
|
||||
|
||||
let params = new URLSearchParams();
|
||||
params.set('hostname', controller.hostname);
|
||||
if (controller.port)
|
||||
params.set('port', controller.port);
|
||||
params.set(controller.https ? 'https' : 'http', '1');
|
||||
if (secret)
|
||||
params.set('secret', secret);
|
||||
|
||||
return base + '/#/setup?' + params.toString();
|
||||
},
|
||||
|
||||
renderButton(uci, config) {
|
||||
let url = this.setupUrl(uci, config);
|
||||
|
||||
if (!url)
|
||||
return null;
|
||||
|
||||
return E('span', { 'class': 'homeproxy-dashboard-action' }, [
|
||||
E('a', {
|
||||
'class': 'btn cbi-button cbi-button-action',
|
||||
'href': url,
|
||||
'target': '_blank',
|
||||
'rel': 'noreferrer noopener'
|
||||
}, [ _('MetaCubeXD Dashboard') ]),
|
||||
E('span', {
|
||||
'style': 'display:block;margin-top:.35em;color:var(--text-color-medium,#666);font-size:90%;'
|
||||
}, _('When using the remote MetaCubeXD dashboard, the Clash API secret is provided to that web page. Use a self-hosted or local dashboard when stronger security is required.'))
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* HomeProxy 配置诊断 helper。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require rpc';
|
||||
|
||||
'require homeproxy as hp';
|
||||
|
||||
const callConfigDiagnostics = rpc.declare({
|
||||
object: 'luci.homeproxy_node_tools',
|
||||
method: 'config_diagnostics',
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
load() {
|
||||
return L.resolveDefault(callConfigDiagnostics(), { result: true, items: [] });
|
||||
},
|
||||
|
||||
show(diagnostics) {
|
||||
return hp.showConfigDiagnostics(diagnostics);
|
||||
}
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* HomeProxy 节点正则预览 helper。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require dom';
|
||||
'require homeproxy as hp';
|
||||
'require rpc';
|
||||
|
||||
const callValidateRegex = rpc.declare({
|
||||
object: 'luci.homeproxy_node_tools',
|
||||
method: 'validate_regex',
|
||||
params: ['pattern'],
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
const callPreviewNodeFilter = rpc.declare({
|
||||
object: 'luci.homeproxy_node_tools',
|
||||
method: 'preview_node_filter',
|
||||
params: ['manual_nodes', 'node_filter', 'node_filter_exclude', 'node'],
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
return baseclass.extend({
|
||||
normalizeNodeList(value) {
|
||||
let nodes = [];
|
||||
|
||||
for (let node_id of hp.toArray(value))
|
||||
if (node_id && !nodes.includes(node_id))
|
||||
nodes.push(node_id);
|
||||
|
||||
return nodes;
|
||||
},
|
||||
|
||||
validate(pattern) {
|
||||
return L.resolveDefault(callValidateRegex(pattern), { result: false, error: _('Unknown error.') });
|
||||
},
|
||||
|
||||
preview(manual_nodes, node_filter, node_filter_exclude, node) {
|
||||
return L.resolveDefault(callPreviewNodeFilter(
|
||||
manual_nodes,
|
||||
node_filter,
|
||||
node_filter_exclude,
|
||||
node
|
||||
), { result: false, error: _('Unknown error.') });
|
||||
},
|
||||
|
||||
setPreviewVisible(container, visible) {
|
||||
let row = container.closest('.cbi-value');
|
||||
if (row)
|
||||
row.style.display = visible ? '' : 'none';
|
||||
},
|
||||
|
||||
renderPreview(container, res, nodeName) {
|
||||
if (!res.result) {
|
||||
let message = _('Expecting: %s').format(_('valid regular expression'));
|
||||
if (res.error)
|
||||
message += ': ' + res.error;
|
||||
|
||||
dom.content(container, E('em', { 'style': 'color: #a33' }, message));
|
||||
return;
|
||||
}
|
||||
|
||||
let nodes = this.normalizeNodeList(res.nodes);
|
||||
|
||||
if (!nodes.length) {
|
||||
dom.content(container, E('em', {}, _('No effective nodes.')));
|
||||
return;
|
||||
}
|
||||
|
||||
let content = [];
|
||||
if (res.truncated)
|
||||
content.push(E('div', { 'style': 'color: #a66; margin-bottom: .35em;' },
|
||||
_('Only the first %d effective nodes are shown. Narrow the regex to avoid generating an oversized node group.').format(res.max_result_nodes || nodes.length)));
|
||||
if (res.scan_truncated)
|
||||
content.push(E('div', { 'style': 'color: #a66; margin-bottom: .35em;' },
|
||||
_('Regex matching stopped after scanning %d nodes. Narrow the regex or reduce subscription size.').format(res.max_scan_nodes || 0)));
|
||||
|
||||
content.push(E('ul', {
|
||||
'style': 'margin: 0; padding-left: 1.4em; max-height: 28em; overflow: auto;'
|
||||
}, nodes.map((node_id) => E('li', {
|
||||
'title': node_id
|
||||
}, nodeName(node_id)))));
|
||||
|
||||
dom.content(container, content);
|
||||
}
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* HomeProxy 路由节点前端 helper。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require uci';
|
||||
|
||||
return baseclass.extend({
|
||||
routingNodeName(res) {
|
||||
let type = _('Routing node');
|
||||
if (res.node === 'urltest')
|
||||
type = _('URLTest');
|
||||
else if (res.node === 'selector')
|
||||
type = _('Selector');
|
||||
|
||||
return String.format('[%s] %s', type, res.label || res['.name']);
|
||||
},
|
||||
|
||||
buildRoutingNodeNames(uci_config) {
|
||||
let names = {};
|
||||
|
||||
uci.sections(uci_config, 'routing_node', (res) => {
|
||||
names[res['.name']] = this.routingNodeName(res);
|
||||
});
|
||||
|
||||
return names;
|
||||
},
|
||||
|
||||
addSelectableOutbounds(option, uci_config, proxy_nodes, section_id, include_routing_nodes) {
|
||||
for (let i in proxy_nodes)
|
||||
option.value(i, proxy_nodes[i]);
|
||||
|
||||
if (!include_routing_nodes)
|
||||
return;
|
||||
|
||||
uci.sections(uci_config, 'routing_node', (res) => {
|
||||
if (res.enabled === '1' && res['.name'] !== section_id)
|
||||
option.value(res['.name'], this.routingNodeName(res));
|
||||
});
|
||||
},
|
||||
|
||||
nodeDisplayName(node_id, proxy_nodes, routing_node_names) {
|
||||
return proxy_nodes[node_id] || routing_node_names[node_id] || node_id;
|
||||
},
|
||||
|
||||
toArray(value) {
|
||||
if (!value)
|
||||
return [];
|
||||
return Array.isArray(value) ? value : [ value ];
|
||||
},
|
||||
|
||||
routingNodeEdges(res) {
|
||||
let edges = [];
|
||||
|
||||
if (res.node === 'selector') {
|
||||
edges = edges.concat(this.toArray(res.selector_nodes));
|
||||
edges = edges.concat(this.toArray(res.selector_default));
|
||||
} else if (res.node === 'urltest') {
|
||||
edges = edges.concat(this.toArray(res.urltest_nodes));
|
||||
} else {
|
||||
edges = edges.concat(this.toArray(res.node));
|
||||
}
|
||||
|
||||
return edges.concat(this.toArray(res.outbound));
|
||||
},
|
||||
|
||||
selectorHasPath(uci_config, start, target, path_cache, seen) {
|
||||
if (!start || !target)
|
||||
return false;
|
||||
if (start === target)
|
||||
return true;
|
||||
|
||||
let key = start + '->' + target;
|
||||
if (path_cache[key] !== undefined)
|
||||
return path_cache[key];
|
||||
|
||||
if (seen[start])
|
||||
return false;
|
||||
|
||||
seen[start] = true;
|
||||
|
||||
let found = false;
|
||||
uci.sections(uci_config, 'routing_node', (res) => {
|
||||
if (found || res['.name'] !== start)
|
||||
return;
|
||||
|
||||
if (res.enabled !== '1' || !res.node)
|
||||
return;
|
||||
|
||||
for (let node_id of this.routingNodeEdges(res)) {
|
||||
if (node_id === target || this.selectorHasPath(uci_config, node_id, target, path_cache, seen)) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
path_cache[key] = found;
|
||||
return found;
|
||||
}
|
||||
});
|
||||
@@ -1,339 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* HomeProxy 节点测速前端 helper。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
|
||||
const MAX_NODES = 300;
|
||||
const HELP_TEXT = _('Speed tests use the current runtime configuration. To test all nodes, add them to a running node group with a node regex such as .*');
|
||||
const running = {};
|
||||
|
||||
const callNodesTcping = rpc.declare({
|
||||
object: 'luci.homeproxy_tcping',
|
||||
method: 'nodes_tcping',
|
||||
params: [ 'sections' ],
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
const callNodeTcping = rpc.declare({
|
||||
object: 'luci.homeproxy_tcping',
|
||||
method: 'node_tcping',
|
||||
params: [ 'section' ],
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
function replaceElementContent(el, content) {
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
while (el.firstChild)
|
||||
el.removeChild(el.firstChild);
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
for (let node of content)
|
||||
el.appendChild((typeof node === 'string') ? document.createTextNode(node) : node);
|
||||
} else {
|
||||
el.appendChild((typeof content === 'string') ? document.createTextNode(content) : content);
|
||||
}
|
||||
}
|
||||
|
||||
function svgElement(tag, attrs, children) {
|
||||
let el = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
||||
|
||||
for (let key in (attrs || {}))
|
||||
el.setAttribute(key, attrs[key]);
|
||||
|
||||
for (let child of (children || []))
|
||||
el.appendChild(child);
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderSpeedtestIcon(extra_class) {
|
||||
return svgElement('svg', {
|
||||
'xmlns': 'http://www.w3.org/2000/svg',
|
||||
'width': '18',
|
||||
'height': '18',
|
||||
'viewBox': '0 0 24 24',
|
||||
'fill': 'none',
|
||||
'stroke': 'currentColor',
|
||||
'stroke-width': '2',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'class': extra_class || ''
|
||||
}, [
|
||||
svgElement('path', { 'd': 'M5.636 19.364a9 9 0 1 1 12.728 0' }),
|
||||
svgElement('path', { 'd': 'M16 9l-4 4' })
|
||||
]);
|
||||
}
|
||||
|
||||
function renderSpinner() {
|
||||
return E('span', { 'class': 'homeproxy-tcping-spinner' });
|
||||
}
|
||||
|
||||
function setButtonIcon(btn, busy) {
|
||||
if (!btn)
|
||||
return;
|
||||
|
||||
replaceElementContent(btn, renderSpeedtestIcon(busy ? 'homeproxy-tcping-icon homeproxy-tcping-icon-busy' : 'homeproxy-tcping-icon'));
|
||||
}
|
||||
|
||||
function nodeId(section_id) {
|
||||
return 'homeproxy-node-tcping-' + String(section_id).replace(/[^A-Za-z0-9_-]/g, (c) =>
|
||||
'_' + c.charCodeAt(0).toString(16) + '_');
|
||||
}
|
||||
|
||||
function ensureStyle() {
|
||||
if (document.getElementById('homeproxy-tcping-style'))
|
||||
return;
|
||||
|
||||
document.head.appendChild(E('style', { 'id': 'homeproxy-tcping-style' }, `
|
||||
@keyframes homeproxy-tcping-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes homeproxy-tcping-pulse { 50% { opacity: .45; } }
|
||||
.homeproxy-tcping-button {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.homeproxy-tcping-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.homeproxy-tcping-icon-busy {
|
||||
animation: homeproxy-tcping-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
.homeproxy-latency-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 4.8em;
|
||||
min-height: 1.7em;
|
||||
padding: .16em .55em;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.3;
|
||||
user-select: none;
|
||||
transition: background-color .15s ease, transform .15s ease;
|
||||
}
|
||||
.homeproxy-latency-pill:hover {
|
||||
background-color: rgba(128, 128, 128, .14);
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.homeproxy-latency-pill:active {
|
||||
transform: scale(.96);
|
||||
}
|
||||
.homeproxy-latency-pill.homeproxy-tcping-busy {
|
||||
color: gray !important;
|
||||
}
|
||||
.homeproxy-tcping-spinner {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 999px;
|
||||
animation: homeproxy-tcping-spin .8s linear infinite;
|
||||
}
|
||||
`));
|
||||
}
|
||||
|
||||
function formatTestedAt(result) {
|
||||
let tested_at = result?.tested_at;
|
||||
if (!tested_at)
|
||||
return '';
|
||||
|
||||
try {
|
||||
return _('Tested at: %s').format(new Date(tested_at * 1000).toLocaleString());
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function setResult(section_id, text, color, title) {
|
||||
let el = document.getElementById(nodeId(section_id));
|
||||
|
||||
if (!el)
|
||||
return false;
|
||||
|
||||
replaceElementContent(el, text);
|
||||
el.title = title || '';
|
||||
el.style.setProperty('color', color || '');
|
||||
el.classList.remove('homeproxy-tcping-busy');
|
||||
el.setAttribute('aria-busy', 'false');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function setBusy(section_id) {
|
||||
let el = document.getElementById(nodeId(section_id));
|
||||
|
||||
if (!el)
|
||||
return false;
|
||||
|
||||
replaceElementContent(el, renderSpinner());
|
||||
el.title = _('Testing...');
|
||||
el.classList.add('homeproxy-tcping-busy');
|
||||
el.setAttribute('aria-busy', 'true');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderResult(section_id, result) {
|
||||
let target = result?.target || '',
|
||||
error = result?.error || '',
|
||||
tested_at = formatTestedAt(result),
|
||||
title = [ target, error, tested_at ].filter((v) => v).join('\n');
|
||||
|
||||
switch (result?.status) {
|
||||
case 'ok':
|
||||
setResult(section_id, '%d ms'.format(result.delay), 'green', title);
|
||||
break;
|
||||
case 'timeout':
|
||||
setResult(section_id, _('Timeout'), 'red', title);
|
||||
break;
|
||||
case 'unsupported':
|
||||
setResult(section_id, _('Unsupported'), 'gray', title);
|
||||
break;
|
||||
case 'invalid':
|
||||
setResult(section_id, _('No address'), 'red', title);
|
||||
break;
|
||||
case 'missing':
|
||||
setResult(section_id, _('Missing'), 'red', title);
|
||||
break;
|
||||
case 'skipped':
|
||||
setResult(section_id, _('Skipped'), 'gray', title);
|
||||
break;
|
||||
case 'unloaded':
|
||||
setResult(section_id, _('Not loaded'), 'gray', title);
|
||||
break;
|
||||
default:
|
||||
setResult(section_id, _('Failed'), 'red', title);
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function runSingle(section_id, notices) {
|
||||
return L.resolveDefault(callNodeTcping(section_id), {}).then((res) => {
|
||||
if (!res.result) {
|
||||
let message = res.error || _('Connectivity test failed.');
|
||||
renderResult(section_id, { status: 'failed', error: message });
|
||||
notices[message] = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.warning)
|
||||
notices[res.warning] = true;
|
||||
|
||||
renderResult(section_id, res.node);
|
||||
}).catch((err) => {
|
||||
let message = err.message || String(err);
|
||||
renderResult(section_id, { status: 'failed', error: message });
|
||||
notices[message] = true;
|
||||
});
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
helpText: HELP_TEXT,
|
||||
|
||||
nodeId: nodeId,
|
||||
ensureStyle: ensureStyle,
|
||||
renderIcon: renderSpeedtestIcon,
|
||||
|
||||
runNode(section_id, ev) {
|
||||
let notices = {};
|
||||
|
||||
if (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
if (running[section_id])
|
||||
return Promise.resolve();
|
||||
|
||||
running[section_id] = true;
|
||||
setBusy(section_id);
|
||||
|
||||
return runSingle(section_id, notices).then(() => {
|
||||
for (let message in notices)
|
||||
ui.addNotification(null, E('p', message));
|
||||
}).finally(() => {
|
||||
running[section_id] = false;
|
||||
});
|
||||
},
|
||||
|
||||
runNodes(section_ids, ev) {
|
||||
let btn = ev?.currentTarget || ev?.target,
|
||||
test_sections = section_ids.slice(0, MAX_NODES),
|
||||
skipped_sections = section_ids.slice(MAX_NODES),
|
||||
notices = {};
|
||||
|
||||
if (!section_ids.length) {
|
||||
ui.addNotification(null, E('p', _('No testable nodes.')));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
let visible = 0;
|
||||
|
||||
for (let section_id of test_sections) {
|
||||
if (setBusy(section_id))
|
||||
visible++;
|
||||
}
|
||||
for (let section_id of skipped_sections)
|
||||
renderResult(section_id, {
|
||||
status: 'skipped',
|
||||
error: _('At most %d nodes can be tested at once.').format(MAX_NODES)
|
||||
});
|
||||
|
||||
if (!visible)
|
||||
ui.addNotification(null, E('p', _('No latency column was found on this page. Force-refresh the page and try again.')));
|
||||
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
setButtonIcon(btn, true);
|
||||
}
|
||||
|
||||
return L.resolveDefault(callNodesTcping(test_sections), {}).then((res) => {
|
||||
if (!res.result)
|
||||
throw new Error(res.error || _('Connectivity test failed.'));
|
||||
|
||||
if (res.warning)
|
||||
notices[res.warning] = true;
|
||||
|
||||
let nodes = res.nodes || {};
|
||||
for (let section_id of test_sections)
|
||||
renderResult(section_id, nodes[section_id]);
|
||||
}).catch((err) => {
|
||||
let message = err.message || String(err);
|
||||
|
||||
for (let section_id of test_sections)
|
||||
renderResult(section_id, { status: 'failed', error: message });
|
||||
|
||||
notices[message] = true;
|
||||
}).then(() => {
|
||||
for (let message in notices)
|
||||
ui.addNotification(null, E('p', message));
|
||||
}).then(() => {
|
||||
if (skipped_sections.length) {
|
||||
let message = _('Skipped %d nodes. At most %d nodes can be tested at once.').format(skipped_sections.length, MAX_NODES);
|
||||
ui.addNotification(null, E('p', message));
|
||||
}
|
||||
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
setButtonIcon(btn, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2026 HomeProxy Custom
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
'require fs';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require view';
|
||||
|
||||
// 当前页面会话里的动态路径状态
|
||||
let currentBackupPath = null;
|
||||
let currentRestorePath = null;
|
||||
|
||||
const callBackupCreate = rpc.declare({
|
||||
object: 'luci.homeproxy_backup',
|
||||
method: 'backup_create',
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
const callBackupGetUploadPath = rpc.declare({
|
||||
object: 'luci.homeproxy_backup',
|
||||
method: 'backup_get_upload_path',
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
const callBackupCleanup = rpc.declare({
|
||||
object: 'luci.homeproxy_backup',
|
||||
method: 'backup_cleanup',
|
||||
params: ['path'],
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
const callBackupValidate = rpc.declare({
|
||||
object: 'luci.homeproxy_backup',
|
||||
method: 'backup_validate',
|
||||
params: ['path'],
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
const callBackupRestore = rpc.declare({
|
||||
object: 'luci.homeproxy_backup',
|
||||
method: 'backup_restore',
|
||||
params: ['path'],
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
function downloadFile(path, filename) {
|
||||
return fs.read_direct(path, 'blob').then((blob) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
let a = document.createElement('a');
|
||||
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.setTimeout(() => {
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFileList(files) {
|
||||
if (!files || !files.length)
|
||||
return E('em', _('No files.'));
|
||||
|
||||
return E('ul', {}, files.map((file) => E('li', {}, file)));
|
||||
}
|
||||
|
||||
function setButtonText(btn, text) {
|
||||
if (btn && btn.firstChild)
|
||||
btn.firstChild.data = text;
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
handleCreateBackup(ev) {
|
||||
const btn = ev.currentTarget || ev.target;
|
||||
|
||||
setButtonText(btn, _('Generating backup file...'));
|
||||
|
||||
return L.resolveDefault(callBackupCreate(), {}).then((res) => {
|
||||
if (!res.result)
|
||||
throw new Error(res.error || _('Failed to generate backup file.'));
|
||||
|
||||
// 记录后端生成的备份路径,供浏览器下载
|
||||
currentBackupPath = res.download_path || res.path;
|
||||
if (!currentBackupPath)
|
||||
throw new Error(_('Backup file path was not returned.'));
|
||||
|
||||
const filename = 'homeproxy-backup-%s.tar.gz'.format((new Date()).toISOString().replace(/[:.]/g, '-'));
|
||||
|
||||
return downloadFile(currentBackupPath, filename).then(() => {
|
||||
ui.addNotification(null, E('p', _('Backup file has been generated and download has started.')), 'info');
|
||||
// 下载后清理后端临时备份文件,避免证书和私钥留在 /tmp
|
||||
return L.resolveDefault(callBackupCleanup(currentBackupPath), {});
|
||||
});
|
||||
}).catch((err) => {
|
||||
ui.addNotification(null, E('p', err.message || err));
|
||||
}).finally(() => {
|
||||
setButtonText(btn, _('Generate backup file'));
|
||||
});
|
||||
},
|
||||
|
||||
handleUploadRestore(ev) {
|
||||
const btn = ev.currentTarget || ev.target;
|
||||
|
||||
setButtonText(btn, _('Uploading backup file...'));
|
||||
|
||||
// 先向后端申请本次上传路径
|
||||
return L.resolveDefault(callBackupGetUploadPath(), {}).then((res) => {
|
||||
if (!res.upload_path)
|
||||
throw new Error(_('Failed to get upload path'));
|
||||
|
||||
currentRestorePath = res.upload_path;
|
||||
|
||||
return ui.uploadFile(currentRestorePath);
|
||||
}).then(() => {
|
||||
setButtonText(btn, _('Checking backup file...'));
|
||||
return L.resolveDefault(callBackupValidate(currentRestorePath), {});
|
||||
}).then((res) => {
|
||||
if (!res.valid)
|
||||
throw new Error(res.error || _('The uploaded backup file is invalid.'));
|
||||
|
||||
ui.showModal(_('Restore HomeProxy backup?'), [
|
||||
E('p', _('The uploaded backup file contains the following HomeProxy source configuration. Restoring it will overwrite the current HomeProxy settings, custom direct/proxy lists, and uploaded certificates and private keys.')),
|
||||
renderFileList(res.files),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'btn',
|
||||
'click': ui.createHandlerFn(this, () => {
|
||||
return L.resolveDefault(callBackupCleanup(currentRestorePath), {}).finally(ui.hideModal);
|
||||
})
|
||||
}, [ _('Cancel') ]),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'btn cbi-button-action important',
|
||||
'click': ui.createHandlerFn(this, 'handleRestoreConfirm')
|
||||
}, [ _('Continue') ])
|
||||
])
|
||||
]);
|
||||
}).catch((err) => {
|
||||
ui.addNotification(null, E('p', err.message || err));
|
||||
return L.resolveDefault(callBackupCleanup(currentRestorePath), {});
|
||||
}).finally(() => {
|
||||
setButtonText(btn, _('Upload backup file...'));
|
||||
});
|
||||
},
|
||||
|
||||
handleRestoreConfirm() {
|
||||
ui.showModal(_('Restoring HomeProxy backup...'), [
|
||||
E('p', { 'class': 'spinning' }, _('Applying backup file and restarting HomeProxy.'))
|
||||
]);
|
||||
|
||||
return L.resolveDefault(callBackupRestore(currentRestorePath), {}).then((res) => {
|
||||
if (!res.result)
|
||||
throw new Error(res.error || _('Restore failed.'));
|
||||
|
||||
ui.showModal(_('Restore complete'), [
|
||||
E('p', _('HomeProxy configuration has been restored and the service has restarted.')),
|
||||
E('p', _('The rollback archive for the previous HomeProxy configuration has been saved to %s.').format(res.rollback || '/tmp/homeproxy-rollback.tar.gz')),
|
||||
renderFileList(res.files),
|
||||
E('div', { 'class': 'right' },
|
||||
E('button', { 'class': 'btn', 'click': ui.hideModal }, [ _('Close') ])
|
||||
)
|
||||
]);
|
||||
}).catch((err) => {
|
||||
ui.addNotification(null, E('p', err.message || err));
|
||||
ui.hideModal();
|
||||
});
|
||||
},
|
||||
|
||||
render() {
|
||||
const readonly = !L.hasViewPermission();
|
||||
|
||||
return E('div', { 'class': 'cbi-map' }, [
|
||||
E('h2', {}, _('HomeProxy configuration backup / restore')),
|
||||
E('div', { 'class': 'cbi-map-descr' },
|
||||
_('Export and import HomeProxy source configuration to fully restore WebUI settings after reinstallation.')),
|
||||
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Backup')),
|
||||
E('div', { 'class': 'cbi-section-descr' },
|
||||
_('Download a tar.gz backup containing /etc/config/homeproxy, custom direct/proxy lists, and uploaded certificates and private keys. The backup contains plaintext private keys; keep it safe and avoid transmitting or storing it through untrusted channels. Restore only verifies file integrity (SHA-256 manifest), not source authenticity.')),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Download backup')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action important',
|
||||
'disabled': readonly || null,
|
||||
'click': ui.createHandlerFn(this, 'handleCreateBackup')
|
||||
}, [ _('Generate backup file') ])
|
||||
])
|
||||
])
|
||||
]),
|
||||
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Restore')),
|
||||
E('div', { 'class': 'cbi-section-descr' },
|
||||
_('Upload a HomeProxy backup file and overwrite the current HomeProxy source configuration. A rollback archive is saved automatically before restore.')),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Restore backup')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action important',
|
||||
'disabled': readonly || null,
|
||||
'click': ui.createHandlerFn(this, 'handleUploadRestore')
|
||||
}, [ _('Upload backup file...') ])
|
||||
])
|
||||
])
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -126,6 +126,8 @@ return view.extend({
|
||||
let m, s, o;
|
||||
let features = data[1];
|
||||
|
||||
hp.installCloseButtonText();
|
||||
|
||||
m = new form.Map('homeproxy', _('HomeProxy Server'),
|
||||
_('The modern ImmortalWrt proxy platform for ARM64/AMD64.'));
|
||||
|
||||
@@ -188,6 +190,7 @@ return view.extend({
|
||||
o.value('tuic', _('Tuic'));
|
||||
o.value('vless', _('VLESS'));
|
||||
o.value('vmess', _('VMess'));
|
||||
o.default = 'mixed';
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.option(form.Value, 'address', _('Listen address'));
|
||||
@@ -637,10 +640,10 @@ return view.extend({
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.ListValue, 'tls_dns01_provider', _('DNS provider'));
|
||||
o.value('alidns', _('Alibaba Cloud DNS'));
|
||||
o.value('cloudflare', _('Cloudflare'));
|
||||
o.value('alidns', _('AliYun'));
|
||||
o.value('cfdns', _('CloudFlare'));
|
||||
o.depends('tls_dns01_challenge', '1');
|
||||
o.default = 'cloudflare';
|
||||
o.default = 'cfdns';
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
@@ -663,7 +666,7 @@ return view.extend({
|
||||
|
||||
o = s.option(form.Value, 'tls_dns01_cf_api_token', _('API token'));
|
||||
o.password = true;
|
||||
o.depends('tls_dns01_provider', 'cloudflare');
|
||||
o.depends('tls_dns01_provider', 'cfdns');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
'require ui';
|
||||
'require view';
|
||||
|
||||
'require homeproxy as hp';
|
||||
|
||||
/* Thanks to luci-app-aria2 */
|
||||
const css = ' \
|
||||
#log_textarea { \
|
||||
@@ -27,6 +29,53 @@ const css = ' \
|
||||
} \
|
||||
.description { \
|
||||
background-color: #33ccff; \
|
||||
} \
|
||||
.homeproxy-connect-grid { \
|
||||
display: grid; \
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));\
|
||||
gap: 12px; \
|
||||
} \
|
||||
.homeproxy-connect-card { \
|
||||
display: grid; \
|
||||
grid-template-columns: 44px 1fr; \
|
||||
gap: 10px; \
|
||||
align-items: center; \
|
||||
min-height: 64px; \
|
||||
padding: 8px 10px; \
|
||||
border-radius: 6px; \
|
||||
background: #fff; \
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, .08);\
|
||||
} \
|
||||
.homeproxy-connect-icon { \
|
||||
display: flex; \
|
||||
align-items: center; \
|
||||
justify-content: center; \
|
||||
width: 38px; \
|
||||
height: 38px; \
|
||||
border-radius: 50%; \
|
||||
color: #fff; \
|
||||
font-weight: 700; \
|
||||
font-size: 15px; \
|
||||
} \
|
||||
.homeproxy-connect-info { \
|
||||
min-height: 42px; \
|
||||
padding: 7px 12px; \
|
||||
border-radius: 8px; \
|
||||
background: #e9edf2; \
|
||||
} \
|
||||
.homeproxy-connect-title { \
|
||||
color: #7d8aa0; \
|
||||
font-weight: 700; \
|
||||
font-size: 13px; \
|
||||
line-height: 1.2; \
|
||||
} \
|
||||
.homeproxy-connect-result { \
|
||||
margin-top: 2px; \
|
||||
color: #ff4d35; \
|
||||
font-weight: 700; \
|
||||
font-size: 13px; \
|
||||
cursor: pointer; \
|
||||
line-height: 1.2; \
|
||||
}';
|
||||
|
||||
const hp_dir = '/var/run/homeproxy';
|
||||
@@ -44,13 +93,13 @@ function getConnStat(o, site) {
|
||||
'class': 'btn cbi-button cbi-button-action',
|
||||
'click': ui.createHandlerFn(this, () => {
|
||||
return L.resolveDefault(callConnStat(site), {}).then((ret) => {
|
||||
let ele = o.default.firstElementChild.nextElementSibling;
|
||||
let ele = o.default.firstElementChild.nextElementSibling;
|
||||
if (ret.result) {
|
||||
ele.style.setProperty('color', 'green');
|
||||
ele.innerHTML = _('passed');
|
||||
ele.innerHTML = _('passed');
|
||||
} else {
|
||||
ele.style.setProperty('color', 'red');
|
||||
ele.innerHTML = _('failed');
|
||||
ele.innerHTML = _('failed');
|
||||
}
|
||||
});
|
||||
})
|
||||
@@ -60,6 +109,53 @@ function getConnStat(o, site) {
|
||||
]);
|
||||
}
|
||||
|
||||
function renderConnCards() {
|
||||
const callConnStat = rpc.declare({
|
||||
object: 'luci.homeproxy',
|
||||
method: 'connection_check',
|
||||
params: ['site'],
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ site: 'baidu', title: _('Baidu Connection'), icon: 'B', color: '#ff7a00' },
|
||||
{ site: 'google', title: _('Google Connection'), icon: 'G', color: '#4285f4' },
|
||||
{ site: 'github', title: _('GitHub Connection'), icon: 'GH', color: '#7b3bb3' },
|
||||
{ site: 'youtube', title: _('YouTube Connection'), icon: 'YT', color: '#ff0033' }
|
||||
];
|
||||
|
||||
return E('div', { 'class': 'homeproxy-connect-grid' }, items.map((item) => {
|
||||
let result = E('div', {
|
||||
'class': 'homeproxy-connect-result',
|
||||
'click': ui.createHandlerFn(this, () => {
|
||||
result.textContent = _('Checking...');
|
||||
result.style.setProperty('color', '#f0ad4e');
|
||||
|
||||
return L.resolveDefault(callConnStat(item.site), {}).then((ret) => {
|
||||
if (ret.result && ret.latency != null) {
|
||||
result.style.setProperty('color', 'green');
|
||||
result.textContent = _('%s ms').format(ret.latency);
|
||||
} else {
|
||||
result.style.setProperty('color', '#ff4d35');
|
||||
result.textContent = _('failed');
|
||||
}
|
||||
});
|
||||
})
|
||||
}, [ _('Click to check') ]);
|
||||
|
||||
return E('div', { 'class': 'homeproxy-connect-card' }, [
|
||||
E('div', {
|
||||
'class': 'homeproxy-connect-icon',
|
||||
'style': 'background:%s'.format(item.color)
|
||||
}, [ item.icon ]),
|
||||
E('div', { 'class': 'homeproxy-connect-info' }, [
|
||||
E('div', { 'class': 'homeproxy-connect-title' }, [ item.title ]),
|
||||
result
|
||||
])
|
||||
]);
|
||||
}));
|
||||
}
|
||||
|
||||
function getResVersion(o, type) {
|
||||
const callResVersion = rpc.declare({
|
||||
object: 'luci.homeproxy',
|
||||
@@ -227,16 +323,16 @@ return view.extend({
|
||||
render() {
|
||||
let m, s, o;
|
||||
|
||||
hp.installCloseButtonText();
|
||||
|
||||
m = new form.Map('homeproxy');
|
||||
|
||||
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Connection check'));
|
||||
s.anonymous = true;
|
||||
|
||||
o = s.option(form.DummyValue, '_check_baidu', _('BaiDu'));
|
||||
o.cfgvalue = L.bind(getConnStat, this, o, 'baidu');
|
||||
|
||||
o = s.option(form.DummyValue, '_check_google', _('Google'));
|
||||
o.cfgvalue = L.bind(getConnStat, this, o, 'google');
|
||||
o = s.option(form.DummyValue, '_connection_check');
|
||||
o.rawhtml = true;
|
||||
o.render = renderConnCards;
|
||||
|
||||
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Resources management'));
|
||||
s.anonymous = true;
|
||||
|
||||
Reference in new issue
Block a user