mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-26 18:12:51 +08:00
443 lines
15 KiB
JavaScript
443 lines
15 KiB
JavaScript
/*
|
|
* SPDX-License-Identifier: GPL-2.0-only
|
|
*
|
|
* Copyright (C) 2022-2025 ImmortalWrt.org
|
|
*/
|
|
|
|
'use strict';
|
|
'require dom';
|
|
'require form';
|
|
'require fs';
|
|
'require poll';
|
|
'require uci';
|
|
'require ui';
|
|
'require view';
|
|
|
|
'require homeproxy-pro as hp';
|
|
|
|
/* Thanks to luci-app-aria2 */
|
|
const css = ' \
|
|
#log_textarea { \
|
|
padding: 10px; \
|
|
text-align: left; \
|
|
} \
|
|
#log_textarea pre { \
|
|
padding: .5rem; \
|
|
word-break: break-all; \
|
|
margin: 0; \
|
|
}';
|
|
|
|
const hp_dir = '/var/run/homeproxy-pro';
|
|
|
|
/* One view-level poll for all three log views.
|
|
*
|
|
* getRuntimeLog() is bound to an option's render() hook, and every map.reset()
|
|
* - any UCI write, a log level change, saving the token, updating a resource -
|
|
* re-renders each of those options. poll.add() in there therefore added a
|
|
* fresh handler per re-render, so every tick read each log file once per
|
|
* leftover handler, and the stale handlers painted the detached textarea of a
|
|
* previous render.
|
|
*
|
|
* This reuses hp.statusPoller() (see client.js), the same once-per-view
|
|
* registrar that fixes the status bar leak: its flag lives inside the returned
|
|
* registrar, so one handler is added no matter how often render() runs. It is
|
|
* called with all three files at once because a single tick reads them
|
|
* together.
|
|
*
|
|
* `log_targets` always holds the textarea installed by the *current* render,
|
|
* so the poll paints into the live DOM without an id lookup - the three
|
|
* textareas share the `#log_textarea` id and cannot be told apart by id. */
|
|
const log_targets = {};
|
|
|
|
function readRuntimeLog(filename) {
|
|
return fs.read_direct(String.format('%s/%s.log', hp_dir, filename), 'text')
|
|
.then((res) => E('pre', { 'wrap': 'pre' }, [
|
|
res.trim() || _('Log is empty.')
|
|
]))
|
|
.catch((err) => {
|
|
if (err.toString().includes('NotFoundError'))
|
|
return E('pre', { 'wrap': 'pre' }, [ _('Log file does not exist.') ]);
|
|
|
|
return E('pre', { 'wrap': 'pre' }, [ _('Unknown error: %s').format(err) ]);
|
|
});
|
|
}
|
|
|
|
const ensureLogPoll = hp.statusPoller({
|
|
poll: poll,
|
|
read: () => Promise.all(Object.keys(log_targets).map((filename) =>
|
|
readRuntimeLog(filename).then((node) => ({ filename: filename, node: node })))),
|
|
paint: (entries) => {
|
|
for (const entry of entries) {
|
|
const target = log_targets[entry.filename];
|
|
if (target)
|
|
dom.content(target, entry.node);
|
|
}
|
|
}
|
|
});
|
|
|
|
/* --- rule-set download failures ------------------------------------------
|
|
*
|
|
* A remote rule-set that cannot be downloaded stops the service: sing-box
|
|
* fetches it during initialization, before the inbounds bind, so a failure
|
|
* there is a failed start. That makes it a loud problem, and this block is
|
|
* here to make it legible rather than to solve it - a start that failed leaves
|
|
* no instance, so sing-box-c.log is the only place the reason is written down.
|
|
*
|
|
* sing-box-c.log is read rather than a new RPC: the ACL already grants the
|
|
* browser read on that file for the log viewer below, so this costs no
|
|
* permission and no new method to keep in step with the method table.
|
|
*
|
|
* The built-in China split is not among the rule-sets this can be about: those
|
|
* are local files the resource updater maintains, generated from the same lists
|
|
* the nft sets are rendered from, and there is nothing to download. */
|
|
const ruleSetNode = E('div', { 'id': 'ruleset_status' },
|
|
E('img', {
|
|
'src': L.resource('icons/loading.svg'),
|
|
'alt': _('Loading'),
|
|
'style': 'vertical-align:middle'
|
|
}, _('Collecting data...'))
|
|
);
|
|
|
|
function renderRuleSetStatus() {
|
|
/* Same poller as the log views, so there is still one handler per view
|
|
* no matter how often render() runs. */
|
|
ensureLogPoll();
|
|
|
|
fs.read_direct(`${hp_dir}/sing-box-c.log`, 'text')
|
|
.then((text) => {
|
|
const failures = hp.parseRuleSetFetchFailures(text);
|
|
|
|
if (!failures.length) {
|
|
dom.content(ruleSetNode, E('span', { 'style': 'color:green' },
|
|
[_('No rule-set download failures in the current log.')]));
|
|
return;
|
|
}
|
|
|
|
dom.content(ruleSetNode, E('div', [
|
|
E('p', { 'style': 'color:red' }, [
|
|
_('%d rule-set(s) cannot be downloaded.').format(failures.length)
|
|
]),
|
|
E('p', { 'style': 'color:gray' }, [
|
|
_('A remote rule-set is fetched before the service starts, so a rule-set that cannot be downloaded prevents the start. The service is running, so the log below is from an earlier attempt or the download has since recovered.')
|
|
]),
|
|
E('ul', {}, failures.map((f) => E('li', [
|
|
E('code', [ f.tag ]),
|
|
' — ',
|
|
/* Text nodes, never markup: the reason is a string
|
|
* sing-box wrote, and it contains a URL. */
|
|
f.reason || _('(no reason recorded)'),
|
|
f.at ? E('small', { 'style': 'color:gray' }, [ ' — ' + f.at ]) : ''
|
|
])))
|
|
]));
|
|
})
|
|
.catch((err) => {
|
|
dom.content(ruleSetNode, E('span', { 'style': 'color:gray' }, [
|
|
_('Cannot read the sing-box client log (%s).').format(String(err))
|
|
]));
|
|
});
|
|
}
|
|
|
|
function getConnStat(o, site) {
|
|
o.default = E('div', { 'style': 'cbi-value-field' }, [
|
|
E('button', {
|
|
'class': 'btn cbi-button cbi-button-action',
|
|
'click': ui.createHandlerFn(this, () => {
|
|
return hp.rpcCall('connection_check', [site],
|
|
{ params: ['site'], expect: { '': {} } }).then((ret) => {
|
|
let ele = o.default.firstElementChild.nextElementSibling;
|
|
/* The address family comes back with the verdict rather than
|
|
* being read here: form.Map has no formvalue() in this LuCI
|
|
* (an earlier revision called it and threw), and the backend
|
|
* is the only layer that knows which family it probed. With
|
|
* IPv6 support on, "passed" now means the IPv6 path works,
|
|
* and a failure says the node cannot carry IPv6 - which is
|
|
* the truth, not a wget retry-order artefact. IPv4/IPv6 are
|
|
* protocol names, so they are appended untranslated. */
|
|
let fam = ret.family ? ' (' + ret.family + ')' : '';
|
|
if (ret.result) {
|
|
ele.style.setProperty('color', 'green');
|
|
ele.innerHTML = _('passed') + fam;
|
|
} else {
|
|
ele.style.setProperty('color', 'red');
|
|
ele.innerHTML = _('failed') + fam;
|
|
}
|
|
});
|
|
})
|
|
}, [ _('Check') ]),
|
|
' ',
|
|
E('strong', { 'style': 'color:gray' }, _('unchecked')),
|
|
]);
|
|
}
|
|
|
|
function getResVersion(o, type) {
|
|
return hp.rpcCall('resources_get_version', [type],
|
|
{ params: ['type'], expect: { '': {} } }).then((res) => {
|
|
/* Review M7: the version string (the upstream commit date) and
|
|
* `res.updated_at` (when *this router* last succeeded) are
|
|
* distinct. Show both: the upstream date stays the same across
|
|
* a successful re-run, so it cannot answer "did the cron job run
|
|
* last night" - only `updated_at` does. */
|
|
const updated_label = res.updated_at
|
|
? _('(last updated %s)').format(res.updated_at)
|
|
: _('(never updated on this device)');
|
|
let spanTemp = E('div', { 'style': 'cbi-value-field' }, [
|
|
E('button', {
|
|
'class': 'btn cbi-button cbi-button-action',
|
|
'click': ui.createHandlerFn(this, () => {
|
|
return hp.rpcCall('resources_update', [type],
|
|
{ params: ['type'], expect: { '': {} } }).then((res) => {
|
|
switch (res.status) {
|
|
case 0:
|
|
o.description = _('Successfully updated.');
|
|
break;
|
|
case 1:
|
|
o.description = _('Update failed.');
|
|
break;
|
|
case 2:
|
|
o.description = _('Already in updating.');
|
|
break;
|
|
case 3:
|
|
o.description = _('Already at the latest version.');
|
|
break;
|
|
default:
|
|
o.description = _('Unknown error.');
|
|
break;
|
|
}
|
|
|
|
return o.map.reset();
|
|
});
|
|
})
|
|
}, [ _('Check update') ]),
|
|
' ',
|
|
E('strong', { 'style': (res.error ? 'color:red' : 'color:green') },
|
|
[ res.error ? 'not found' : res.version ]
|
|
),
|
|
' ',
|
|
E('small', { 'style': 'color:gray' }, [ updated_label ]),
|
|
]);
|
|
|
|
o.default = spanTemp;
|
|
});
|
|
}
|
|
|
|
/* `filename` is the log's basename (and log_clean's type argument), passed in
|
|
* by the caller. It used to be reverse-engineered from the option name with
|
|
* `o.option.split('_')[1]`, which only works while every option is spelled
|
|
* `_<filename>_logview` - rename one and the reader, the poll and the clean
|
|
* button silently disagree about which file they mean.
|
|
*
|
|
* The parameter sits after `name`, before the framework's
|
|
* (option_index, section_id, in_table): L.bind prepends the bound arguments,
|
|
* so the call sites pass it and the framework keeps handing in the rest. */
|
|
function getRuntimeLog(o, name, filename, _option_index, section_id, _in_table) {
|
|
let section, log_level_el;
|
|
switch (filename) {
|
|
case 'homeproxy-pro':
|
|
section = null;
|
|
break;
|
|
case 'sing-box-c':
|
|
section = 'config';
|
|
break;
|
|
case 'sing-box-s':
|
|
section = 'server';
|
|
break;
|
|
}
|
|
|
|
if (section) {
|
|
const selected = uci.get('homeproxy-pro', section, 'log_level') || 'warn';
|
|
const choices = {
|
|
trace: _('Trace'),
|
|
debug: _('Debug'),
|
|
info: _('Info'),
|
|
warn: _('Warn'),
|
|
error: _('Error'),
|
|
fatal: _('Fatal'),
|
|
panic: _('Panic')
|
|
};
|
|
|
|
log_level_el = E('select', {
|
|
'id': o.cbid(section_id),
|
|
'class': 'cbi-input-select',
|
|
'style': 'margin-left: 4px; width: 6em;',
|
|
'change': ui.createHandlerFn(this, (ev) => {
|
|
uci.set('homeproxy-pro', section, 'log_level', ev.target.value);
|
|
return o.map.save(null, true).then(() => {
|
|
ui.changes.apply(true);
|
|
});
|
|
})
|
|
});
|
|
|
|
Object.keys(choices).forEach((v) => {
|
|
log_level_el.appendChild(E('option', {
|
|
'value': v,
|
|
'selected': (v === selected) ? '' : null
|
|
}, [ choices[v] ]));
|
|
});
|
|
}
|
|
|
|
const log_textarea = E('div', { 'id': 'log_textarea' },
|
|
E('img', {
|
|
'src': L.resource('icons/loading.svg'),
|
|
'alt': _('Loading'),
|
|
'style': 'vertical-align:middle'
|
|
}, _('Collecting data...'))
|
|
);
|
|
|
|
log_targets[filename] = log_textarea;
|
|
ensureLogPoll();
|
|
|
|
return E([
|
|
E('style', [ css ]),
|
|
E('div', {'class': 'cbi-map'}, [
|
|
E('h3', {'name': 'content', 'style': 'align-items: center; display: flex;'}, [
|
|
_('%s log').format(name),
|
|
log_level_el || '',
|
|
E('button', {
|
|
'class': 'btn cbi-button cbi-button-action',
|
|
'style': 'margin-left: 4px;',
|
|
'click': ui.createHandlerFn(this, () => {
|
|
return hp.rpcCall('log_clean', [filename],
|
|
{ params: ['type'], expect: { '': {} } });
|
|
})
|
|
}, [ _('Clean log') ])
|
|
]),
|
|
E('div', {'class': 'cbi-section'}, [
|
|
log_textarea,
|
|
E('div', {'style': 'text-align:right'},
|
|
E('small', {}, _('Refresh every %s seconds.').format(L.env.pollinterval))
|
|
)
|
|
])
|
|
])
|
|
]);
|
|
}
|
|
|
|
return view.extend({
|
|
render() {
|
|
let m, s, o;
|
|
|
|
m = new form.Map('homeproxy-pro');
|
|
|
|
s = m.section(form.NamedSection, 'config', 'homeproxy-pro', _('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');
|
|
|
|
s = m.section(form.NamedSection, 'config', 'homeproxy-pro', _('Resources management'));
|
|
s.anonymous = true;
|
|
|
|
o = s.option(form.DummyValue, '_china_ip4_version', _('China IPv4 list version'));
|
|
o.cfgvalue = L.bind(getResVersion, this, o, 'china_ip4');
|
|
o.rawhtml = true;
|
|
|
|
o = s.option(form.DummyValue, '_china_ip6_version', _('China IPv6 list version'));
|
|
o.cfgvalue = L.bind(getResVersion, this, o, 'china_ip6');
|
|
o.rawhtml = true;
|
|
|
|
o = s.option(form.DummyValue, '_china_list_version', _('China list version'));
|
|
o.cfgvalue = L.bind(getResVersion, this, o, 'china_list');
|
|
o.rawhtml = true;
|
|
|
|
o = s.option(form.DummyValue, '_gfw_list_version', _('GFW list version'));
|
|
o.cfgvalue = L.bind(getResVersion, this, o, 'gfw_list');
|
|
o.rawhtml = true;
|
|
|
|
o = s.option(form.Value, 'github_token', _('GitHub token'));
|
|
o.password = true;
|
|
o.renderWidget = function() {
|
|
let node = form.Value.prototype.renderWidget.apply(this, arguments);
|
|
|
|
(node.querySelector('.control-group') || node).appendChild(E('button', {
|
|
'class': 'cbi-button cbi-button-apply',
|
|
'title': _('Save'),
|
|
'click': ui.createHandlerFn(this, () => {
|
|
return this.map.save(null, true).then(() => {
|
|
ui.changes.apply(true);
|
|
});
|
|
})
|
|
}, [ _('Save') ]));
|
|
|
|
return node;
|
|
}
|
|
|
|
s = m.section(form.NamedSection, 'config', 'homeproxy-pro', _('Rule sets'));
|
|
s.anonymous = true;
|
|
|
|
/* A dedicated section rather than a fourth log view: the question is
|
|
* "is any rule-set currently broken", and the answer has to be a
|
|
* verdict plus a per-tag reason, not a wall of log the user has to read
|
|
* a timestamp out of. */
|
|
o = s.option(form.DummyValue, '_ruleset_status');
|
|
o.rawhtml = true;
|
|
o.render = function() {
|
|
renderRuleSetStatus();
|
|
return ruleSetNode;
|
|
};
|
|
|
|
/* A dedicated section rather than a fourth log view: the question this
|
|
* answers is "collect everything someone would ask me to look at, in
|
|
* one file", and the answer is a download rather than something to read
|
|
* on the page. The report is assembled by the backend because the
|
|
* commands that fill it (nft, ip rule, the package queries) are not
|
|
* browser-reachable; the ACL grants read on the output path only.
|
|
*
|
|
* The title is "Diagnostic report" and not the more natural
|
|
* "Diagnostics" on purpose: a shipped zh-cn catalog somewhere in the
|
|
* LuCI stack already renders a bare "Diagnostics" as 网络诊断, which
|
|
* reads as a network diagnostic tool rather than a generated file.
|
|
* A msgid this specific cannot collide with another package's. */
|
|
s = m.section(form.NamedSection, 'config', 'homeproxy-pro', _('Diagnostic report'));
|
|
s.anonymous = true;
|
|
|
|
o = s.option(form.DummyValue, '_debug_report');
|
|
o.rawhtml = true;
|
|
o.render = function() {
|
|
return E('div', { 'class': 'cbi-value' }, [
|
|
E('p', {}, _('Collects system, dependency, routing, firewall and configuration state into one file. Credentials are masked by option name; public addresses and LAN topology are not. Read it before posting it anywhere.')),
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-action',
|
|
'click': ui.createHandlerFn(this, () => {
|
|
return hp.rpcCall('debug_report', [], { expect: { '': {} } })
|
|
.then((res) => {
|
|
if (!res.result)
|
|
throw new Error(res.error || _('Could not build the report.'));
|
|
return fs.read_direct(res.path, 'blob');
|
|
})
|
|
.then((blob) => {
|
|
const url = window.URL.createObjectURL(blob, { type: 'text/markdown' });
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = 'homeproxy-pro-debug.log';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
window.URL.revokeObjectURL(url);
|
|
});
|
|
})
|
|
}, [ _('Generate and download') ])
|
|
]);
|
|
};
|
|
|
|
s = m.section(form.NamedSection, 'config', 'homeproxy-pro');
|
|
s.anonymous = true;
|
|
|
|
o = s.option(form.DummyValue, '_homeproxy_logview');
|
|
o.render = L.bind(getRuntimeLog, this, o, _('HomeProxy'), 'homeproxy-pro');
|
|
|
|
o = s.option(form.DummyValue, '_sing-box-c_logview');
|
|
o.render = L.bind(getRuntimeLog, this, o, _('sing-box client'), 'sing-box-c');
|
|
|
|
o = s.option(form.DummyValue, '_sing-box-s_logview');
|
|
o.render = L.bind(getRuntimeLog, this, o, _('sing-box server'), 'sing-box-s');
|
|
|
|
return m.render();
|
|
},
|
|
|
|
handleSaveApply: null,
|
|
handleSave: null,
|
|
handleReset: null
|
|
});
|