mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 20:05:13 +08:00
🍉 Sync 2026-09-25 18:00:12
This commit is contained in:
1 parent
7c17dda4d0
commit
2ee65a261e
135 files changed
+5726
-7799
No files matched your search
@@ -0,0 +1,211 @@
|
||||
'use strict';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
|
||||
// Remote URL parsing mirrors remoteurl.sh: the Settings form reacts to it
|
||||
// live, so it cannot be an rpc round trip. Plain ES5 for the buildbot's jsmin.c.
|
||||
//
|
||||
// Every E() child is passed as an ARRAY: LuCI's dom.append() turns array
|
||||
// items into text nodes but assigns a bare string to innerHTML, and diff
|
||||
// bodies, commit subjects and error reasons come off the backup remote.
|
||||
// tests/dom_text_children.test.mjs enforces this in every view.
|
||||
|
||||
var callStatus = rpc.declare({
|
||||
object: 'luci.gitbackup',
|
||||
method: 'status'
|
||||
});
|
||||
|
||||
var callHistory = rpc.declare({
|
||||
object: 'luci.gitbackup',
|
||||
method: 'history',
|
||||
params: [ 'limit' ]
|
||||
});
|
||||
|
||||
var callTest = rpc.declare({
|
||||
object: 'luci.gitbackup',
|
||||
method: 'test'
|
||||
});
|
||||
|
||||
// Same three forms as gb_parse_url: https, ssh:// and scp-like.
|
||||
function gbParseRemote(url) {
|
||||
var m;
|
||||
|
||||
url = url || '';
|
||||
|
||||
m = url.match(/^https:\/\/([^/:]+)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
||||
if (!m)
|
||||
m = url.match(/^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
||||
if (!m)
|
||||
m = url.match(/^(?:[^@/:]+@)?([^/:]+):([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
||||
|
||||
return m ? { host: m[1], owner: m[2], repo: m[3] } : null;
|
||||
}
|
||||
|
||||
function gbProvider(url, option) {
|
||||
var parsed;
|
||||
|
||||
if (option && option !== 'auto')
|
||||
return option;
|
||||
|
||||
parsed = gbParseRemote(url);
|
||||
switch (parsed ? parsed.host : '') {
|
||||
case 'github.com':
|
||||
return 'github';
|
||||
case 'gitlab.com':
|
||||
return 'gitlab';
|
||||
case 'bitbucket.org':
|
||||
return 'bitbucket';
|
||||
case 'codeberg.org':
|
||||
return 'gitea';
|
||||
default:
|
||||
return 'generic';
|
||||
}
|
||||
}
|
||||
|
||||
function gbWebBase(parsed, provider) {
|
||||
switch (provider) {
|
||||
case 'github':
|
||||
return 'https://github.com/' + parsed.owner + '/' + parsed.repo;
|
||||
case 'bitbucket':
|
||||
return 'https://bitbucket.org/' + parsed.owner + '/' + parsed.repo;
|
||||
case 'gitlab':
|
||||
case 'gitea':
|
||||
return 'https://' + parsed.host + '/' + parsed.owner + '/' + parsed.repo;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var GB_DEPLOY_KEY_PATH = {
|
||||
github: '/settings/keys/new',
|
||||
gitlab: '/-/settings/repository',
|
||||
gitea: '/settings/keys',
|
||||
bitbucket: '/admin/access-keys/'
|
||||
};
|
||||
|
||||
var GB_COMMIT_PATH = {
|
||||
github: '/commit/',
|
||||
gitlab: '/-/commit/',
|
||||
gitea: '/commit/',
|
||||
bitbucket: '/commits/'
|
||||
};
|
||||
|
||||
function gbDeployKeyLink(url, option) {
|
||||
var parsed = gbParseRemote(url);
|
||||
var provider = gbProvider(url, option);
|
||||
var base;
|
||||
|
||||
if (!parsed)
|
||||
return { kind: 'unparsed' };
|
||||
|
||||
base = gbWebBase(parsed, provider);
|
||||
return base ?
|
||||
{ kind: 'link', url: base + GB_DEPLOY_KEY_PATH[provider] } :
|
||||
{ kind: 'generic', host: parsed.host };
|
||||
}
|
||||
|
||||
function gbCommitUrl(url, option, sha) {
|
||||
var parsed = gbParseRemote(url);
|
||||
var provider = gbProvider(url, option);
|
||||
var base = (parsed && sha) ? gbWebBase(parsed, provider) : null;
|
||||
|
||||
return base ? base + GB_COMMIT_PATH[provider] + sha : null;
|
||||
}
|
||||
|
||||
var GB_CSS = [
|
||||
'.gitbackup-view { container-type: inline-size; }',
|
||||
'.gitbackup-view [hidden] { display: none; }',
|
||||
'.gitbackup-caption, .gitbackup-card-hint, .gitbackup-hint { font-size: .9em; color: var(--text-color-medium, #666); }',
|
||||
'.gitbackup-hint-ok { color: var(--success-color-high, #2e7d32); }',
|
||||
'.gitbackup-hint-warn { color: var(--warn-color-high, #b45f06); }',
|
||||
'.gitbackup-hint-error { color: var(--error-color-high, #c62828); }',
|
||||
'.gitbackup-actions { display: flex; flex-wrap: wrap; gap: .5em; margin: .75em 0; align-items: center; }',
|
||||
'.gitbackup-log { max-height: 320px; overflow: auto; background: var(--background-color-low, #f5f5f5); color: var(--text-color-high, #333); border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .6em .8em; font-family: monospace; font-size: .85em; white-space: pre-wrap; }',
|
||||
'.gitbackup-leak-list { margin: .3em 0; padding-left: 1.2em; color: var(--text-color-high, #333); }',
|
||||
'.gitbackup-diff { max-height: 420px; overflow: auto; background: var(--background-color-low, #f5f5f5); border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .6em .8em; font-family: monospace; font-size: .85em; white-space: pre-wrap; }',
|
||||
'.gitbackup-diffline-add { display: block; color: var(--success-color-high, #2e7d32); }',
|
||||
'.gitbackup-diffline-del { display: block; color: var(--error-color-high, #c62828); }',
|
||||
'.gitbackup-diffline-change { display: block; color: var(--warn-color-high, #b45f06); }',
|
||||
'.gitbackup-diffline-hunk { display: block; color: var(--text-color-medium, #666); }',
|
||||
'.gitbackup-diffline-meta { display: block; font-weight: bold; color: var(--text-color-medium, #666); }',
|
||||
'.gitbackup-diffline-ctx { display: block; color: var(--text-color-high, #333); }'
|
||||
];
|
||||
|
||||
function gbUnifiedLineClass(line) {
|
||||
if (line.indexOf('+++') === 0 || line.indexOf('---') === 0)
|
||||
return 'gitbackup-diffline-meta';
|
||||
if (line.charAt(0) === '+')
|
||||
return 'gitbackup-diffline-add';
|
||||
if (line.charAt(0) === '-')
|
||||
return 'gitbackup-diffline-del';
|
||||
if (line.indexOf('@@') === 0)
|
||||
return 'gitbackup-diffline-hunk';
|
||||
if (line.indexOf('diff --git') === 0 || line.indexOf('index ') === 0)
|
||||
return 'gitbackup-diffline-meta';
|
||||
return 'gitbackup-diffline-ctx';
|
||||
}
|
||||
|
||||
function gbManifestLineClass(line) {
|
||||
if (line.indexOf('+ ') === 0)
|
||||
return 'gitbackup-diffline-add';
|
||||
if (line.indexOf('- ') === 0)
|
||||
return 'gitbackup-diffline-del';
|
||||
if (line.indexOf('~ ') === 0)
|
||||
return 'gitbackup-diffline-change';
|
||||
return 'gitbackup-diffline-ctx';
|
||||
}
|
||||
|
||||
function gbBuildPre(text, classify) {
|
||||
if (!text)
|
||||
return E('p', { 'class': 'gitbackup-card-hint' }, [ _('No differences.') ]);
|
||||
|
||||
return E('pre', { 'class': 'gitbackup-diff' }, text.split('\n').map(function(line) {
|
||||
return E('span', { 'class': classify(line) }, [ line + '\n' ]);
|
||||
}));
|
||||
}
|
||||
|
||||
return L.Class.extend({
|
||||
callStatus: callStatus,
|
||||
callHistory: callHistory,
|
||||
callTest: callTest,
|
||||
|
||||
css: GB_CSS,
|
||||
|
||||
provider: gbProvider,
|
||||
|
||||
deployKeyLink: gbDeployKeyLink,
|
||||
commitUrl: gbCommitUrl,
|
||||
|
||||
commitTime: function(subject) {
|
||||
var m = (subject || '').match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2})/);
|
||||
return m ? m[1] : null;
|
||||
},
|
||||
|
||||
leakList: function() {
|
||||
return E('ul', { 'class': 'gitbackup-leak-list' }, [
|
||||
E('li', {}, [ '/etc/shadow' ]),
|
||||
E('li', {}, [ _('dropbear private host keys') ]),
|
||||
E('li', {}, [ 'authorized_keys' ]),
|
||||
E('li', {}, [ _('WPA pre-shared keys (Wi-Fi passwords)') ]),
|
||||
E('li', {}, [ _('WireGuard private keys') ]),
|
||||
E('li', {}, [ _('PPPoE credentials') ]),
|
||||
E('li', {}, [ 'uhttpd.key' ])
|
||||
]);
|
||||
},
|
||||
|
||||
// ' ' text nodes between buttons match LuCI's own modal spacing.
|
||||
modalButtons: function(dismissLabel, actions) {
|
||||
var children = [ E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ dismissLabel || _('Close') ]) ];
|
||||
|
||||
(actions || []).forEach(function(a) { children.push(' ', a); });
|
||||
return E('div', { 'class': 'right' }, children);
|
||||
},
|
||||
|
||||
unified: function(text) {
|
||||
return gbBuildPre(text, gbUnifiedLineClass);
|
||||
},
|
||||
|
||||
manifest: function(text) {
|
||||
return gbBuildPre(text, gbManifestLineClass);
|
||||
}
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// gitbackup -- shared diff viewer (ticket 23, spec "Не пиши второй
|
||||
// просмотрщик diff"). Before this file existed, history.js was the only
|
||||
// place in the project that rendered a diff into a colored <pre>; ticket 23
|
||||
// adds a second place that needs one (Overview's "Configuration" card, R124/
|
||||
// R125's own config_diff indicator, which used to show only the bare
|
||||
// differs/does-not-differ flag and threw the actual text away). A second,
|
||||
// separately-typed copy of history.js's own gbDiffLineClass/gbBuildDiffPre
|
||||
// would be exactly the "третья реализация" ticket 23 explicitly forbids, so
|
||||
// both call sites use this file instead.
|
||||
//
|
||||
// This is deliberately TWO renderers, not one that auto-detects its input,
|
||||
// because the two rpcd methods that feed them answer genuinely different
|
||||
// questions in genuinely different formats -- collapsing them into one
|
||||
// "smart" parser would have to guess which format it was looking at from
|
||||
// the text alone, and a manifest line that happened to start with "+" or
|
||||
// "-" (this format's own add/remove marker) would be indistinguishable from
|
||||
// a coincidence:
|
||||
//
|
||||
// unified(text) -- for `diff` (gbrpc_diff, two already-committed shas,
|
||||
// History's per-commit view): plain `git diff --end-of-options <a> <b>`
|
||||
// output (usr/sbin/gitbackup's cmd_diff, its two-argument form) -- real
|
||||
// unified-diff syntax, "diff --git a/X b/X" headers, "@@ ... @@" hunks,
|
||||
// body lines prefixed ' '/'+'/'-'.
|
||||
//
|
||||
// manifest(text) -- for `config_diff` (gbrpc_config_diff, no params,
|
||||
// Overview's live-drift check): cmd_diff's OTHER, zero-argument form,
|
||||
// which never calls `git diff` at all -- it walks both manifests'
|
||||
// entries field by field (_gb_diff_compare/_gb_diff_describe_change,
|
||||
// usr/sbin/gitbackup) and prints one line per changed path: "+ path
|
||||
// (type)" (added), "- path (type)" (removed), or "~ path: field
|
||||
// a->b; ..." (changed) -- the only way this project can show a chmod/
|
||||
// chown a real `git diff` never sees at all (same blob, same tree entry
|
||||
// mode class either way; interfaces.md, ticket 17/D03).
|
||||
//
|
||||
// No content goes through innerHTML anywhere below -- every line is its own
|
||||
// text node built through E(), the same "чужие данные никогда не
|
||||
// становятся разметкой" rule history.js's own diff viewer already followed
|
||||
// (that file's own header comment, out of this ticket's zone, read before
|
||||
// writing this).
|
||||
//
|
||||
// That holds only because every child below is handed to E() INSIDE AN
|
||||
// ARRAY, and this file originally got that wrong. LuCI's dom.append()
|
||||
// branches on the shape of its `children` argument: the Array branch is the
|
||||
// one that runs each string through document.createTextNode(), while a bare
|
||||
// string falls all the way through to `node.innerHTML = '' + children` and
|
||||
// is parsed as markup. `E('span', attrs, line)` therefore rendered a diff
|
||||
// line AS HTML -- and the text here is `git diff` output, i.e. the contents
|
||||
// of files fetched from the backup remote, which anyone with push access to
|
||||
// that repository controls byte for byte. `E('span', attrs, [ line ])` is
|
||||
// the fix, and the bracket is load-bearing, not punctuation: nothing else
|
||||
// escapes on the way here (gb_json_esc escapes only what JSON needs, LuCI's
|
||||
// String.format('%s') does not escape at all, and a LuCI page carries no
|
||||
// CSP). tests/dom_text_children.test.mjs holds the whole view directory to
|
||||
// this, so a future call site cannot quietly drop the brackets again.
|
||||
|
||||
// css -- the class names both renderers below emit, meant to be spliced
|
||||
// into each CALLING view's own <style> block (rule 1 of the style guide:
|
||||
// every view returns its styles inside its own tree, never through
|
||||
// document.head.appendChild -- a shared module has no tree of its own to
|
||||
// return one in). A plain exported array of rule strings, not a <style>
|
||||
// this file injects itself, keeps that rule intact while still giving both
|
||||
// call sites the exact same rules instead of two hand-copied approximations
|
||||
// of them.
|
||||
var GB_DIFFVIEW_CSS = [
|
||||
'.gitbackup-diff { max-height: 420px; overflow: auto; background: var(--background-color-low, #f5f5f5); border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .6em .8em; font-family: monospace; font-size: .85em; white-space: pre-wrap; }',
|
||||
'.gitbackup-diffline-add { display: block; color: var(--success-color-high, #2e7d32); }',
|
||||
'.gitbackup-diffline-del { display: block; color: var(--error-color-high, #c62828); }',
|
||||
'.gitbackup-diffline-change { display: block; color: var(--warn-color-high, #b45f06); }',
|
||||
'.gitbackup-diffline-hunk { display: block; color: var(--text-color-medium, #666); }',
|
||||
'.gitbackup-diffline-meta { display: block; font-weight: bold; color: var(--text-color-medium, #666); }',
|
||||
'.gitbackup-diffline-ctx { display: block; color: var(--text-color-high, #333); }'
|
||||
];
|
||||
|
||||
// gbUnifiedLineClass <line> -- one prefixed class per line of a real `git
|
||||
// diff` (spec: "Diff-viewer -- свой <pre> с префиксованными классами и
|
||||
// токенами --success-*/--error-*. Никаких внешних библиотек подсветки").
|
||||
// Moved here byte-for-byte from history.js's own gbDiffLineClass.
|
||||
function gbUnifiedLineClass(line) {
|
||||
if (line.indexOf('+++') === 0 || line.indexOf('---') === 0)
|
||||
return 'gitbackup-diffline-meta';
|
||||
if (line.charAt(0) === '+')
|
||||
return 'gitbackup-diffline-add';
|
||||
if (line.charAt(0) === '-')
|
||||
return 'gitbackup-diffline-del';
|
||||
if (line.indexOf('@@') === 0)
|
||||
return 'gitbackup-diffline-hunk';
|
||||
if (line.indexOf('diff --git') === 0 || line.indexOf('index ') === 0)
|
||||
return 'gitbackup-diffline-meta';
|
||||
return 'gitbackup-diffline-ctx';
|
||||
}
|
||||
|
||||
// gbManifestLineClass <line> -- one prefixed class per line of cmd_diff's
|
||||
// zero-argument, manifest-field form: "+ "/"- " mark a whole path added or
|
||||
// removed (green/red, same coloring a real diff's own +/- lines get),
|
||||
// "~ " marks a path whose fields changed in place (warn, since it is
|
||||
// neither a pure addition nor a pure removal -- e.g. a chmod), anything
|
||||
// else (there should be nothing else) falls back to plain context styling.
|
||||
function gbManifestLineClass(line) {
|
||||
if (line.indexOf('+ ') === 0)
|
||||
return 'gitbackup-diffline-add';
|
||||
if (line.indexOf('- ') === 0)
|
||||
return 'gitbackup-diffline-del';
|
||||
if (line.indexOf('~ ') === 0)
|
||||
return 'gitbackup-diffline-change';
|
||||
return 'gitbackup-diffline-ctx';
|
||||
}
|
||||
|
||||
// gbBuildPre <text> <classify> <emptyText> -- shared DOM builder: an empty
|
||||
// or blank <text> renders <emptyText> as a plain hint paragraph (spec: "diff
|
||||
// на неизменившейся системе печатает пустой результат" -- both cmd_diff
|
||||
// shapes agree on this), anything else becomes one <span> text node per
|
||||
// line inside a scrollable <pre class="gitbackup-diff">, classified by
|
||||
// <classify>.
|
||||
function gbBuildPre(text, classify, emptyText) {
|
||||
var lines = (text || '').split('\n');
|
||||
|
||||
if (!lines.length || (lines.length === 1 && !lines[0]))
|
||||
return E('p', { 'class': 'gitbackup-card-hint' }, [ emptyText ]);
|
||||
|
||||
return E('pre', { 'class': 'gitbackup-diff' }, lines.map(function(line) {
|
||||
return E('span', { 'class': classify(line) }, [ line + '\n' ]);
|
||||
}));
|
||||
}
|
||||
|
||||
return L.Class.extend({
|
||||
css: GB_DIFFVIEW_CSS,
|
||||
|
||||
// unified(text) -- History's per-commit diff (two already-committed
|
||||
// shas): real `git diff` output.
|
||||
unified: function(text) {
|
||||
return gbBuildPre(text, gbUnifiedLineClass, _('No differences.'));
|
||||
},
|
||||
|
||||
// manifest(text) -- Overview's live-drift diff (config_diff): cmd_diff's
|
||||
// own "+ path (type)" / "- path (type)" / "~ path: field a->b; ..."
|
||||
// lines.
|
||||
manifest: function(text) {
|
||||
return gbBuildPre(text, gbManifestLineClass, _('No differences.'));
|
||||
}
|
||||
});
|
||||
@@ -2,50 +2,10 @@
|
||||
'require view';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require view.gitbackup.diffview as diffview';
|
||||
'require view.gitbackup.common as common';
|
||||
'require view.gitbackup.oplog as oplog';
|
||||
/* global diffview, oplog */
|
||||
// See overview.js's own identical comment: the project's eslint config
|
||||
// (openwrt/luci's own) only knows a fixed list of stock LuCI module names
|
||||
// as globals, not this app's own diffview.js/oplog.js aliases.
|
||||
|
||||
// gitbackup -- History (ticket 14, spec "Решения по реализации -> LuCI ->
|
||||
// History", "Формат коммита", "Восстановление"). Client-side JS only,
|
||||
// luci-base only, same jsmin-conservative style (plain ES5, `var`/
|
||||
// `function`, no let/const/arrow/template literals, every statement
|
||||
// semicolon-terminated) as overview.js (ticket 11) and paths.js (ticket 13),
|
||||
// both read before writing this file -- their style/rpcd/poller-cleanup
|
||||
// patterns are reused here rather than reinvented.
|
||||
//
|
||||
// Brief, verbatim: "History -- последние N коммитов своей ветки -- дата из
|
||||
// subject, SHA, список изменившихся путей. Клик -> diff в ui.showModal.
|
||||
// Кнопка Restore с двойным подтверждением: первый экран показывает список
|
||||
// перезаписываемых файлов, второй требует ввести имя устройства; если
|
||||
// board.json выбранного коммита не совпадает с текущим, на первом же
|
||||
// экране -- красное предупреждение о том, что имена интерфейсов и wireless
|
||||
// не подойдут."
|
||||
//
|
||||
// ---------------------------------------------------------------------
|
||||
// rpcd bindings -- copied from root/usr/share/rpcd/acl.d/luci-app-
|
||||
// gitbackup.json and usr/libexec/rpcd/luci.gitbackup's own gbrpc_*
|
||||
// handlers (package/gitbackup, out of this ticket's zone). This view adds
|
||||
// no rpcd method of its own: `status` (device name for the restore
|
||||
// confirmation gate), `history` (the commit list), `diff` (two already-
|
||||
// committed shas -- interfaces.md, ticket 13's own "Чем пользоваться":
|
||||
// "diff -- твоё") and `restore` (write tier) are the whole surface it
|
||||
// needs.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
var callStatus = rpc.declare({
|
||||
object: 'luci.gitbackup',
|
||||
method: 'status'
|
||||
});
|
||||
|
||||
var callHistory = rpc.declare({
|
||||
object: 'luci.gitbackup',
|
||||
method: 'history',
|
||||
params: [ 'limit' ]
|
||||
});
|
||||
/* global common, oplog */
|
||||
// openwrt/luci's eslint config only knows stock module names as globals.
|
||||
|
||||
var callDiff = rpc.declare({
|
||||
object: 'luci.gitbackup',
|
||||
@@ -53,52 +13,16 @@ var callDiff = rpc.declare({
|
||||
params: [ 'from', 'to' ]
|
||||
});
|
||||
|
||||
var callLog = rpc.declare({
|
||||
object: 'luci.gitbackup',
|
||||
method: 'log',
|
||||
params: [ 'lines' ]
|
||||
});
|
||||
|
||||
var callRestore = rpc.declare({
|
||||
object: 'luci.gitbackup',
|
||||
method: 'restore',
|
||||
params: [ 'device', 'commit', 'dry_run', 'force', 'with_packages' ]
|
||||
});
|
||||
|
||||
// "Последние N коммитов" (brief, verbatim) -- one fixed page size, not a
|
||||
// user-adjustable control: the brief never asks for pagination, and
|
||||
// `history` already runs a real `git fetch` per call (interfaces.md,
|
||||
// ticket 11-13: "Долгие вызовы (config_diff, history) не вешаются на
|
||||
// первичную загрузку страницы"), so a bigger N only means a slower single
|
||||
// load, not a cheaper one.
|
||||
// Every `history` call is a real git fetch.
|
||||
var GB_HISTORY_LIMIT = 30;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pure helpers -- no DOM access, easy to reason about and to keep in sync
|
||||
// with the shell side by inspection.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// gbCommitTime <subject> -- the leading "YYYY-MM-DD HH:MM" a backup
|
||||
// commit's subject always starts with (spec "Формат коммита", G03: "по
|
||||
// списку коммитов в вебе видно, когда снят каждый, без открывания") --
|
||||
// same extraction overview.js's own gbCommitTime already does for the
|
||||
// single most recent commit; duplicated here rather than shared because
|
||||
// each view here is its own self-contained file, same convention
|
||||
// paths.js's own private GB_CSS/gbSequentialMap already follow.
|
||||
function gbCommitTime(subject) {
|
||||
var m = (subject || '').match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2})/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// gbShortenRepoPath <path> -- a path as it appears in this device's own
|
||||
// branch tree (e.g. "devices/r1/files/etc/config/network",
|
||||
// "devices/r1/meta/board.json", "devices/r1/manifest.json") rewritten to
|
||||
// what an operator actually recognizes: the real absolute path under
|
||||
// "files/", or a short "meta/..." / "manifest.json" label for anything
|
||||
// else. The device-specific "devices/<id>/" prefix itself is never
|
||||
// hard-coded here -- collect.sh's own `path_prefix` (gitbackup.main.
|
||||
// path_prefix) can be a custom template, so this looks for the "/files/"
|
||||
// or "/meta/" marker instead of assuming a fixed prefix shape.
|
||||
// path_prefix is a template, so look for the marker, not a fixed prefix.
|
||||
function gbShortenRepoPath(path) {
|
||||
var idx = path.indexOf('/files/');
|
||||
if (idx !== -1)
|
||||
@@ -114,12 +38,6 @@ function gbShortenRepoPath(path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// gbSummarizeList <items[]> <max> -- "a, b, c" or, past <max> entries,
|
||||
// "a, b, c and N more" -- the same "до трёх... больше трёх -- и ещё N"
|
||||
// convention the commit subject itself already uses (spec "Формат
|
||||
// коммита"), applied here to `changed[]` (history's own full per-commit
|
||||
// path list, which for a device's very first backup commit is every file
|
||||
// it has -- unabridged, unlike the subject's own three-item cap).
|
||||
function gbSummarizeList(items, max) {
|
||||
if (!items.length)
|
||||
return '';
|
||||
@@ -128,21 +46,6 @@ function gbSummarizeList(items, max) {
|
||||
return items.slice(0, max).join(', ') + ' ' + _('and %d more').format(items.length - max);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Diff parsing -- `diff` (gbrpc_diff) hands back plain `git diff <a> <b>`
|
||||
// text, unrestricted (interfaces.md: "diff принимает два уже закоммиченных
|
||||
// sha... и даёт git diff между ними"). Everything below reads that text
|
||||
// only; no assumption is made beyond git's own documented unified-diff
|
||||
// output shape (`diff --git a/X b/X`, optional "new file mode"/"deleted
|
||||
// file mode", "--- "/"+++ " headers, "@@ ... @@" hunks, body lines
|
||||
// prefixed ' '/'+'/'-').
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// gbDiffSections <text> -- one entry per "diff --git " block: { header,
|
||||
// body[] } (body = every following line up to, not including, the next
|
||||
// "diff --git " line). The one grouping both the file list and the
|
||||
// board.json comparison below are built from, so the two can never
|
||||
// disagree about where one file's diff ends and the next begins.
|
||||
function gbDiffSections(text) {
|
||||
var lines = (text || '').split('\n');
|
||||
var sections = [];
|
||||
@@ -161,18 +64,11 @@ function gbDiffSections(text) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
// gbSectionPath <header> -- the "b/..." side of a "diff --git a/X b/X"
|
||||
// line. Git always fills in the same path on both sides for a plain add
|
||||
// or delete (no rename detection requested), so this never needs the
|
||||
// "a/" side at all.
|
||||
function gbSectionPath(header) {
|
||||
var m = header.match(/^diff --git a\/(.+) b\/(.+)$/);
|
||||
return m ? m[2] : null;
|
||||
}
|
||||
|
||||
// gbSectionStatus <section> -- 'added' | 'deleted' | 'modified', from
|
||||
// git's own "new file mode" / "deleted file mode" body lines (present
|
||||
// only for those two cases).
|
||||
function gbSectionStatus(section) {
|
||||
var i;
|
||||
for (i = 0; i < section.body.length; i++) {
|
||||
@@ -184,28 +80,14 @@ function gbSectionStatus(section) {
|
||||
return 'modified';
|
||||
}
|
||||
|
||||
// gbDiffFileList <text> -- [{ path, status }], one per changed file.
|
||||
function gbDiffFileList(text) {
|
||||
return gbDiffSections(text).map(function(s) {
|
||||
return { path: gbSectionPath(s.header), status: gbSectionStatus(s) };
|
||||
}).filter(function(f) { return f.path; });
|
||||
}
|
||||
|
||||
// gbBoardMismatch <text> -- true when the diff between the current branch
|
||||
// tip and the commit about to be restored changed meta/board.json's own
|
||||
// "model" or "target" field. Mirrors restore.sh's own authoritative check
|
||||
// (_gb_restore_check_board: "model" plus "release.target", the same two
|
||||
// fields device.sh's board strategy and the armsr/generic-target case
|
||||
// both rely on -- interfaces.md's own note that `model` is `null` on
|
||||
// those targets) rather than "board.json changed at all": board.json also
|
||||
// carries a plain `hostname` field, and flagging every rename as a board
|
||||
// mismatch would train an operator to ignore the warning. This can only
|
||||
// approximate restore.sh's live `ubus call system board` comparison with
|
||||
// the closest thing a read-only rpcd surface exposes -- the most recent
|
||||
// commit's own meta/board.json, i.e. what the last successful backup run
|
||||
// saw. restore.sh itself remains the actual, authoritative gate (it always
|
||||
// re-checks against the live board and refuses without --force); this is
|
||||
// only the up-front warning the brief asks for.
|
||||
// The same two fields restore.sh checks against the live board; restore.sh
|
||||
// stays the real gate, this is only the up-front warning.
|
||||
function gbBoardMismatch(text) {
|
||||
var sections = gbDiffSections(text);
|
||||
var i, j, path, line, ch;
|
||||
@@ -230,30 +112,12 @@ function gbBoardMismatch(text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// gbFilesPath <path> -- the real absolute filesystem path for an entry
|
||||
// under ".../files/...", or null for anything else (meta/*, manifest.json).
|
||||
// `restore` (restore.sh's own gb_restore) only ever writes manifest
|
||||
// entries -- i.e. the "files/" tree -- back onto the router; it never
|
||||
// touches meta/board.json, manifest.json or any other bookkeeping file on
|
||||
// disk. A changed-file list built straight from `gbDiffFileList` would
|
||||
// therefore mislabel bookkeeping-only commits (a manifest.json-only diff,
|
||||
// say) as "these files will be overwritten" when nothing on disk would
|
||||
// actually change.
|
||||
function gbFilesPath(path) {
|
||||
var idx = path.indexOf('/files/');
|
||||
return idx === -1 ? null : path.substring(idx + '/files'.length);
|
||||
}
|
||||
|
||||
// gbRestorePlan <diffText> -- [{ path, action }], action is 'create' (the
|
||||
// path exists in the target commit but not in the current branch tip, so
|
||||
// restore will write it fresh) or 'overwrite' (exists in both, content
|
||||
// differs). A file that exists in the current tip but NOT in the target
|
||||
// ('deleted' in git-diff terms, moving tip -> target) is deliberately
|
||||
// left out: restore.sh has no delete pass at all (spec: "разложить файлы,
|
||||
// затем применить mode/uid/gid... пересоздать симлинки" -- nothing about
|
||||
// removing a file the target commit does not mention), so that path would
|
||||
// simply be left untouched by a real restore, not "removed", and listing
|
||||
// it as something that will be "overwritten" would be flatly wrong.
|
||||
// A file only in the tip is left out: restore has no delete pass.
|
||||
function gbRestorePlan(diffText) {
|
||||
var files = gbDiffFileList(diffText);
|
||||
var plan = [];
|
||||
@@ -271,40 +135,8 @@ function gbRestorePlan(diffText) {
|
||||
return plan;
|
||||
}
|
||||
|
||||
// The colored-<pre> diff viewer itself moved to diffview.js (ticket 23:
|
||||
// "не пиши второй просмотрщик diff") once Overview needed one too, for a
|
||||
// different diff format (config_diff's manifest-field lines, not a real
|
||||
// `git diff`) -- see that file's own header comment for why this is two
|
||||
// explicit renderers in one shared place rather than one that guesses.
|
||||
// handleViewDiff below calls diffview.unified(res.diff): `diff` (gbrpc_diff,
|
||||
// two already-committed shas) is real `git diff --end-of-options <a> <b>`
|
||||
// output (usr/sbin/gitbackup's cmd_diff, its two-argument form), exactly
|
||||
// what diffview.unified expects.
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Restore log classification -- restore.sh's own gb_log calls (all of
|
||||
// them: success, every refusal, every fetch/checkout failure) go through
|
||||
// `logger -t gitbackup` (lib.sh's gb_log), which `log`/logread already
|
||||
// tag-filters -- the exact same mechanism overview.js's own live-log
|
||||
// poller already reads for `run`/`test`. Restore's plain `printf` on
|
||||
// success ("restored %s from %s") is NOT captured here: gbrpc_restore
|
||||
// backgrounds the CLI with its stdout sent to /dev/null (same shape as
|
||||
// run/test), so only the gb_log line right before it ("gb_restore:
|
||||
// restored $device from $target on $branch") is ever visible through this
|
||||
// poller -- reason this regex keys on that line, not the printf one.
|
||||
// ---------------------------------------------------------------------
|
||||
// Ticket 23: 'writing one or more files failed' used to sit here instead of
|
||||
// the line below, and never matched anything restore.sh actually logs --
|
||||
// found while tracing ticket 19's own change (restore.sh, _gb_restore:
|
||||
// "a partially-restored router with correct permissions... always tells
|
||||
// the operator the truth (below) with a non-zero exit" prints
|
||||
// "gb_restore: the following paths were NOT written:<list>" via gb_log,
|
||||
// never the old string) against this poller's own terminal-line set. With
|
||||
// the wrong string here, a partial restore (ticket 19's own headline
|
||||
// outcome -- content written, permissions applied, exit 1) never matched
|
||||
// GB_RESTORE_TERMINAL_RE at all and this poller ran all the way to its own
|
||||
// 60s-idle/5-minute ceiling before saying anything, exactly the
|
||||
// "непонятно, чем кончилось" ticket 23 exists to fix.
|
||||
// restore.sh's success printf goes to /dev/null, so the gb_log line is what
|
||||
// counts. Checked against tests/fixtures/terminal-markers.tsv.
|
||||
var GB_RESTORE_TERMINAL_RE = new RegExp(
|
||||
[
|
||||
'gb_restore: restored .+ from .+ on ',
|
||||
@@ -320,24 +152,8 @@ var GB_RESTORE_TERMINAL_RE = new RegExp(
|
||||
].join('|')
|
||||
);
|
||||
|
||||
function gbRestoreLogSuccess(line) {
|
||||
return line.indexOf('gb_restore: restored ') !== -1;
|
||||
}
|
||||
|
||||
// gbClassifyRestoreLog <line> -- the human-readable, three-severity outcome
|
||||
// behind GB_RESTORE_TERMINAL_RE's own match (ticket 23: "каждая долгая
|
||||
// операция заканчивается явным сообщением... причина неудачи написана
|
||||
// человеческим языком"). 'partial' is its own kind, deliberately distinct
|
||||
// from both 'success' and 'error' -- ticket 19's restore.sh always applies
|
||||
// permissions to whatever DID get written even when something else did not
|
||||
// (spec: "частичный успех... обязан быть виден и отличаться от полного"),
|
||||
// and folding it into either extreme would misreport which one actually
|
||||
// happened. 'blocked' covers the one refusal that is a safety check working
|
||||
// as intended (a board mismatch neither this view's own heuristic nor the
|
||||
// operator's confirmation caught), kept apart from a genuine 'error' (a
|
||||
// network/fetch/config problem) since the next step for each is different.
|
||||
function gbClassifyRestoreLog(line) {
|
||||
if (gbRestoreLogSuccess(line))
|
||||
if (line.indexOf('gb_restore: restored ') !== -1)
|
||||
return { kind: 'success', message: _('Restore finished. This router now matches the backup you selected.') };
|
||||
|
||||
if (line.indexOf('the following paths were NOT written:') !== -1)
|
||||
@@ -373,11 +189,6 @@ function gbClassifyRestoreLog(line) {
|
||||
return { kind: 'error', message: _('Restore failed -- see the log below for details.') };
|
||||
}
|
||||
|
||||
// gbRestoreSeverity <kind> -- gbClassifyRestoreLog's own four kinds
|
||||
// collapsed to the three severities showRestoreResult below actually
|
||||
// styles: 'success' -> ok, 'partial'/'blocked' -> warn (something is worth
|
||||
// a second look, but this is not the network/config failures 'error'
|
||||
// means), 'error' (and anything unrecognized) -> error.
|
||||
function gbRestoreSeverity(kind) {
|
||||
if (kind === 'success')
|
||||
return 'ok';
|
||||
@@ -387,10 +198,6 @@ function gbRestoreSeverity(kind) {
|
||||
}
|
||||
|
||||
var GB_CSS = [
|
||||
'.gitbackup-view { container-type: inline-size; }',
|
||||
'.gitbackup-card-hint { font-size: .9em; color: var(--text-color-medium, #666); }',
|
||||
'.gitbackup-banner { border-radius: 4px; padding: .6em 1em; margin: 0 0 .75em; background: var(--error-color-medium, #f44336); color: var(--on-error-color, #fff); }',
|
||||
'.gitbackup-banner p { margin: .2em 0; }',
|
||||
'.gitbackup-history-list { list-style: none; margin: .75em 0; padding: 0; }',
|
||||
'.gitbackup-history-row { border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .6em .9em; margin: 0 0 .6em; background: var(--background-color-low, #f5f5f5); }',
|
||||
'.gitbackup-history-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .5em 1em; }',
|
||||
@@ -406,37 +213,17 @@ var GB_CSS = [
|
||||
'.gitbackup-restore-action-create { color: var(--success-color-high, #2e7d32); }',
|
||||
'.gitbackup-restore-action-overwrite { color: var(--warn-color-high, #b45f06); }',
|
||||
'.gitbackup-confirm-input { width: 100%; box-sizing: border-box; margin: .5em 0; }',
|
||||
'.gitbackup-modal-actions { display: flex; justify-content: flex-end; gap: .5em; margin-top: 1em; }',
|
||||
'.gitbackup-log { max-height: 320px; overflow: auto; background: var(--background-color-low, #f5f5f5); color: var(--text-color-high, #333); border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .6em .8em; font-family: monospace; font-size: .85em; white-space: pre-wrap; }',
|
||||
// gitbackup-hint-ok/-warn/-error -- same three severities settings.js's
|
||||
// own copy already uses (ticket 12); reused here (ticket 23) for
|
||||
// showRestoreResult's own success/partial-or-blocked/error message.
|
||||
'.gitbackup-hint-ok { color: var(--success-color-high, #2e7d32); }',
|
||||
'.gitbackup-hint-warn { color: var(--warn-color-high, #b45f06); }',
|
||||
'.gitbackup-hint-error { color: var(--error-color-high, #c62828); }',
|
||||
'.gitbackup-restore-outcome { font-weight: bold; }'
|
||||
];
|
||||
|
||||
return view.extend({
|
||||
// Not a config form -- see overview.js's own identical comment. Rule 11
|
||||
// of the spec's style guide: the stock Save/Apply/Reset row is switched
|
||||
// off through this documented hook, never hidden with CSS.
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null,
|
||||
|
||||
// history is a git fetch; render() starts it after the first paint.
|
||||
load: function() {
|
||||
// `status` only (device name for the second restore-confirmation
|
||||
// screen) -- local file reads, same "cheap" bucket overview.js's
|
||||
// own load() puts status/log/service in. `history` itself is
|
||||
// deliberately NOT here: it is a real `git fetch` against the
|
||||
// configured remote with no timeout of its own (interfaces.md,
|
||||
// tickets 11/13: "Долгие вызовы... не вешаются на первичную
|
||||
// загрузку страницы" -- measured live at several seconds against a
|
||||
// real GitHub remote). render() below starts it once the rest of
|
||||
// the page is already on screen, same pattern overview.js's own
|
||||
// refreshHistory() already uses.
|
||||
return L.resolveDefault(callStatus(), null);
|
||||
return L.resolveDefault(common.callStatus(), null);
|
||||
},
|
||||
|
||||
render: function(status) {
|
||||
@@ -447,37 +234,18 @@ return view.extend({
|
||||
self._commits = null;
|
||||
|
||||
view = E('div', { 'class': 'gitbackup-view' }, [
|
||||
E('style', { 'type': 'text/css' }, [ GB_CSS.concat(diffview.css).join('\n') ]),
|
||||
E('style', { 'type': 'text/css' }, [ common.css.concat(GB_CSS).join('\n') ]),
|
||||
|
||||
E('h2', {}, [ _('Git Backup - History / Restore') ]),
|
||||
|
||||
// Ticket 22: found-in-the-field complaint was "restore exists
|
||||
// but nobody looks for it on a tab called History" -- this
|
||||
// hint is the fix's cheap half (the tab rename above is the
|
||||
// other): it states the round-trip capability up front, in
|
||||
// plain text, with no hover required, before a single commit
|
||||
// row has even loaded.
|
||||
E('p', { 'class': 'gitbackup-card-hint' },
|
||||
[ _('You can restore this router to any backup listed below, using that backup\'s own "Restore" button. Click "View diff" first to see exactly what it would change.') ]),
|
||||
|
||||
E('div', { 'id': 'gitbackup-history-body' }, [
|
||||
E('p', { 'class': 'gitbackup-card-hint' }, [ _('Loading…') ])
|
||||
])
|
||||
|
||||
// Ticket 23: the old page-level "#gitbackup-restore-log" <pre>
|
||||
// that used to live here is gone -- the confirmation modal
|
||||
// itself now stays open for the whole restore and shows the
|
||||
// live log and the final outcome in place (see
|
||||
// handleConfirmRestore/showRestoreProgress/showRestoreResult
|
||||
// below), instead of hiding itself immediately and leaving an
|
||||
// operator to notice a page element they were never told to
|
||||
// watch (spec: "модальное окно... не должно просто
|
||||
// закрываться... а в конце говорит, что восстановлено").
|
||||
]);
|
||||
|
||||
// Fired once render() has already returned the tree above, exactly
|
||||
// like overview.js's own refreshHistory() -- see load()'s own
|
||||
// comment for why `history` cannot run inside load() itself.
|
||||
self.refreshHistory();
|
||||
|
||||
return view;
|
||||
@@ -486,7 +254,7 @@ return view.extend({
|
||||
refreshHistory: function() {
|
||||
var self = this;
|
||||
|
||||
return L.resolveDefault(callHistory(GB_HISTORY_LIMIT), null).then(function(res) {
|
||||
return L.resolveDefault(common.callHistory(GB_HISTORY_LIMIT), null).then(function(res) {
|
||||
self._commits = (res && res.commits) || null;
|
||||
self._historyErr = (!res) ?
|
||||
_('Could not reach the remote repository.') :
|
||||
@@ -525,7 +293,7 @@ return view.extend({
|
||||
|
||||
buildHistoryRow: function(commit, parentSha) {
|
||||
var self = this;
|
||||
var time = gbCommitTime(commit.subject);
|
||||
var time = common.commitTime(commit.subject);
|
||||
var changed = (commit.changed || []).map(gbShortenRepoPath);
|
||||
|
||||
return E('li', { 'class': 'gitbackup-history-row' }, [
|
||||
@@ -549,19 +317,8 @@ return view.extend({
|
||||
]);
|
||||
},
|
||||
|
||||
// handleViewDiff <commit> <parentSha> -- the per-commit diff (spec:
|
||||
// "Клик -> diff в ui.showModal"), i.e. what THAT backup changed versus
|
||||
// the one right before it. `history`'s commits are newest-first and
|
||||
// this branch's own push machinery (gitio.sh's gb_commit_push) always
|
||||
// commits with exactly one parent, so the previous ROW's sha (already
|
||||
// known client-side) is that parent -- no extra round trip needed to
|
||||
// find it. The oldest row on screen has no such neighbour: its actual
|
||||
// parent (if any) sits outside this page's own GB_HISTORY_LIMIT
|
||||
// window, and fabricating a diff against the wrong commit would be
|
||||
// worse than admitting this view simply does not know it.
|
||||
// The previous row is the only parent; the oldest row's is off the page.
|
||||
handleViewDiff: function(commit, parentSha, ev) {
|
||||
var self = this;
|
||||
|
||||
if (!parentSha) {
|
||||
ui.addNotification(null, E('p', {},
|
||||
[ _('No earlier backup loaded to compare against -- this is the oldest commit in this list.') ]), 'info');
|
||||
@@ -574,27 +331,18 @@ return view.extend({
|
||||
if (!res || typeof res.diff !== 'string') {
|
||||
ui.showModal(_('Diff'), [
|
||||
E('p', {}, [ _('Could not load the diff: %s').format((res && res.reason) || _('unknown error')) ]),
|
||||
E('div', { 'class': 'gitbackup-modal-actions' }, [
|
||||
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Close') ])
|
||||
])
|
||||
common.modalButtons()
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
ui.showModal(_('Diff: %s').format(gbCommitTime(commit.subject) || commit.sha.substring(0, 12)), [
|
||||
diffview.unified(res.diff),
|
||||
E('div', { 'class': 'gitbackup-modal-actions' }, [
|
||||
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Close') ])
|
||||
])
|
||||
ui.showModal(_('Diff: %s').format(common.commitTime(commit.subject) || commit.sha.substring(0, 12)), [
|
||||
common.unified(res.diff),
|
||||
common.modalButtons()
|
||||
]);
|
||||
});
|
||||
},
|
||||
|
||||
// handleRestoreClick <commit> -- step 0: fetch the one diff this whole
|
||||
// confirmation flow is built on (current branch tip -> the commit
|
||||
// about to be restored) before showing anything, so the first
|
||||
// confirmation screen (spec: "первый экран показывает список
|
||||
// перезаписываемых файлов") is never a guess.
|
||||
handleRestoreClick: function(commit, ev) {
|
||||
var self = this;
|
||||
var headSha = (self._commits && self._commits[0] && self._commits[0].sha) || commit.sha;
|
||||
@@ -610,23 +358,19 @@ return view.extend({
|
||||
});
|
||||
},
|
||||
|
||||
// showRestoreConfirm1 -- first confirmation screen (spec, verbatim
|
||||
// above): the list of files that will actually be written, and, when
|
||||
// applicable, the board-mismatch warning ahead of any second screen --
|
||||
// never after.
|
||||
showRestoreConfirm1: function(commit, plan, mismatch) {
|
||||
var self = this;
|
||||
var body = [];
|
||||
|
||||
if (mismatch) {
|
||||
body.push(E('div', { 'class': 'gitbackup-banner' }, [
|
||||
body.push(E('div', { 'class': 'alert-message error' }, [
|
||||
E('p', {}, [ _('This backup was taken on a different router model than the one it would be restored onto. Interface names and wireless radios from that hardware will not match this one -- restoring anyway may leave the network unreachable until the configuration is fixed by hand.') ])
|
||||
]));
|
||||
}
|
||||
|
||||
body.push(E('p', {},
|
||||
[ _('Restoring the backup from %s (%s) will write the following files:')
|
||||
.format(gbCommitTime(commit.subject) || commit.sha, commit.sha.substring(0, 12)) ]));
|
||||
.format(common.commitTime(commit.subject) || commit.sha, commit.sha.substring(0, 12)) ]));
|
||||
|
||||
if (plan.length) {
|
||||
body.push(E('ul', { 'class': 'gitbackup-restore-files' }, plan.map(function(p) {
|
||||
@@ -640,8 +384,7 @@ return view.extend({
|
||||
[ _('No file differs from the current commit -- restoring will not change anything on disk.') ]));
|
||||
}
|
||||
|
||||
body.push(E('div', { 'class': 'gitbackup-modal-actions' }, [
|
||||
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Cancel') ]),
|
||||
body.push(common.modalButtons(_('Cancel'), [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative',
|
||||
'click': ui.createHandlerFn(self, 'showRestoreConfirm2', commit, mismatch)
|
||||
@@ -651,14 +394,6 @@ return view.extend({
|
||||
ui.showModal(_('Restore backup?'), body);
|
||||
},
|
||||
|
||||
// showRestoreConfirm2 -- second confirmation screen (spec, verbatim:
|
||||
// "второй требует ввести имя устройства"): the operator has to type
|
||||
// this device's own name back, the same friction a destructive cloud
|
||||
// console action usually asks for. `status.device` (gitbackup status's
|
||||
// own `device` field, interfaces.md tickets 01/08-10) is the device
|
||||
// this router itself resolves to -- and the one `restore` is about to
|
||||
// be called for, since History only ever shows this router's own
|
||||
// branch.
|
||||
showRestoreConfirm2: function(commit, mismatch, ev) {
|
||||
var self = this;
|
||||
var device = self._status.device || '';
|
||||
@@ -683,50 +418,29 @@ return view.extend({
|
||||
ui.showModal(_('Confirm restore'), [
|
||||
E('p', {}, [ _('Type this device\'s name (%s) to confirm restoring it.').format(device || _('(device name not configured)')) ]),
|
||||
input,
|
||||
E('div', { 'class': 'gitbackup-modal-actions' }, [
|
||||
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Cancel') ]),
|
||||
confirmBtn
|
||||
])
|
||||
common.modalButtons(_('Cancel'), [ confirmBtn ])
|
||||
]);
|
||||
},
|
||||
|
||||
// handleConfirmRestore -- fires `restore` (write tier) and switches to
|
||||
// oplog.js's shared bounded live-log poller (ticket 23: "не изобретать
|
||||
// четвёртый механизм" -- the exact pattern overview.js's own
|
||||
// handleRun/handleTest use for run/test). The call itself only starts
|
||||
// the CLI in the background (gbrpc_restore always answers
|
||||
// `{started:true}` immediately) and can take a while for real (network
|
||||
// fetch of the target commit, sha256 verification of every file), so
|
||||
// this must not block the page.
|
||||
//
|
||||
// Ticket 23's own headline complaint about restore specifically:
|
||||
// the confirmation modal used to call ui.hideModal() right here and
|
||||
// let the page's own <pre> carry the live log silently underneath --
|
||||
// closing the one dialog that had the operator's attention just as an
|
||||
// irreversible, file-system-rewriting operation started, then saying
|
||||
// nothing further unless it failed to even START. It no longer hides
|
||||
// at all: showRestoreProgress below replaces this same dialog's
|
||||
// content in place, and stays open, showing the live log, until
|
||||
// showRestoreResult replaces it again with the actual outcome --
|
||||
// success, partial, or failure, spelled out in words (spec: "пусть
|
||||
// остаётся, показывает ход и в конце говорит, что восстановлено, что
|
||||
// не удалось и что делать дальше").
|
||||
//
|
||||
// `force` is passed whenever the board-mismatch warning fired on the
|
||||
// previous screen -- without it restore.sh's own
|
||||
// _gb_restore_check_board refuses outright (exit 4), which would turn
|
||||
// an operator's informed "restore anyway" into a silent no-op visible
|
||||
// only by reading the log by hand.
|
||||
// `force` goes along when the board warning was shown, otherwise
|
||||
// restore.sh refuses the mismatch outright.
|
||||
handleConfirmRestore: function(commit, mismatch, ev) {
|
||||
var self = this;
|
||||
var device = self._status.device || '';
|
||||
|
||||
self.showRestoreProgress();
|
||||
// No Cancel: restore.sh cannot be interrupted once it writes.
|
||||
ui.showModal(_('Restoring…'), [
|
||||
E('p', { 'class': 'spinning' },
|
||||
[ _('Restoring the backup -- this can take a while. Do not power off the router.') ]),
|
||||
E('pre', { 'class': 'gitbackup-log', 'id': 'gitbackup-restore-modal-log' }, [ '' ])
|
||||
]);
|
||||
|
||||
return oplog.start({
|
||||
fetch: function() { return L.resolveDefault(callLog(500), null).then(function(res) { return (res && res.text) || ''; }); },
|
||||
return oplog.run({
|
||||
call: function() {
|
||||
return callRestore(device, commit.sha, undefined, mismatch ? true : undefined);
|
||||
},
|
||||
pre: 'gitbackup-restore-modal-log',
|
||||
terminalRe: GB_RESTORE_TERMINAL_RE,
|
||||
onProgress: function(add) { self.appendRestoreProgress(add); },
|
||||
onFinish: function(line) {
|
||||
self.showRestoreResult(gbClassifyRestoreLog(line));
|
||||
self.refreshHistory();
|
||||
@@ -737,59 +451,15 @@ return view.extend({
|
||||
message: _('No result from the restore after a while -- check the log below or the syslog by hand.')
|
||||
});
|
||||
self.refreshHistory();
|
||||
},
|
||||
onFail: function(reason) {
|
||||
self.showRestoreResult({ kind: 'error', message: reason ?
|
||||
_('Could not start the restore: %s').format(reason) :
|
||||
_('Could not start the restore.') });
|
||||
}
|
||||
}).then(function() {
|
||||
return callRestore(device, commit.sha, undefined, mismatch ? true : undefined);
|
||||
}).then(function(res) {
|
||||
if (!res || res.started !== true) {
|
||||
oplog.stop();
|
||||
self.showRestoreResult({ kind: 'error', message: _('Could not start the restore.') });
|
||||
}
|
||||
}).catch(function(e) {
|
||||
oplog.stop();
|
||||
self.showRestoreResult({ kind: 'error', message: _('Could not start the restore: %s').format(e.message) });
|
||||
});
|
||||
},
|
||||
|
||||
// showRestoreProgress -- replaces the confirmation modal's own content
|
||||
// with a spinner and an empty live-log pane, IN THE SAME DIALOG (no
|
||||
// ui.hideModal() anywhere in this whole flow) -- see
|
||||
// handleConfirmRestore's own comment for why staying open matters here
|
||||
// specifically. No Close/Cancel control on purpose: restore.sh has no
|
||||
// interrupt handling once it starts writing, so a "Cancel" here could
|
||||
// only ever stop watching, never stop the restore itself, and offering
|
||||
// it would suggest otherwise.
|
||||
showRestoreProgress: function() {
|
||||
ui.showModal(_('Restoring…'), [
|
||||
E('p', { 'class': 'spinning' },
|
||||
[ _('Restoring the backup -- this can take a while. Do not power off the router.') ]),
|
||||
E('pre', { 'class': 'gitbackup-log', 'id': 'gitbackup-restore-modal-log' }, [ '' ])
|
||||
]);
|
||||
},
|
||||
|
||||
appendRestoreProgress: function(add) {
|
||||
var pre = document.getElementById('gitbackup-restore-modal-log');
|
||||
|
||||
if (!pre)
|
||||
return;
|
||||
|
||||
pre.textContent = pre.textContent ? pre.textContent + '\n' + add : add;
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
},
|
||||
|
||||
// showRestoreResult <cls> -- the final screen of the restore flow:
|
||||
// <cls> is gbClassifyRestoreLog's own {kind, message} (or the
|
||||
// onTimeout/could-not-start shapes handleConfirmRestore builds inline
|
||||
// above, both the same shape). Reads the progress pane's own
|
||||
// accumulated text (still in the DOM at this point, since this is the
|
||||
// same ui.showModal call that is about to replace it) rather than
|
||||
// keeping a second copy of it in JS state.
|
||||
//
|
||||
// Ticket 23's own explicit ask beyond "say what happened": "что делать
|
||||
// дальше" for the one outcome that actually has a next step worth
|
||||
// naming -- 'partial' (ticket 19's own headline case: content written,
|
||||
// permissions applied, one or more paths failed) points at the log
|
||||
// right below it, already showing exactly which paths and why.
|
||||
showRestoreResult: function(cls) {
|
||||
var pre = document.getElementById('gitbackup-restore-modal-log');
|
||||
var fullLog = pre ? pre.textContent : '';
|
||||
@@ -805,9 +475,7 @@ return view.extend({
|
||||
if (fullLog)
|
||||
body.push(E('pre', { 'class': 'gitbackup-log' }, [ fullLog ]));
|
||||
|
||||
body.push(E('div', { 'class': 'gitbackup-modal-actions' }, [
|
||||
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Close') ])
|
||||
]));
|
||||
body.push(common.modalButtons());
|
||||
|
||||
ui.showModal(title, body);
|
||||
}
|
||||
|
||||
@@ -1,145 +1,124 @@
|
||||
'use strict';
|
||||
'require poll';
|
||||
'require rpc';
|
||||
|
||||
// run/test/restore are backgrounded by rpcd ({started:true}); the only way to
|
||||
// see how they went is to tail `log` until a terminal line appears. LuCI hands
|
||||
// every view the same instance: one operation at a time per view.
|
||||
|
||||
var callLog = rpc.declare({
|
||||
object: 'luci.gitbackup',
|
||||
method: 'log',
|
||||
params: [ 'lines' ]
|
||||
});
|
||||
|
||||
function gbFetchLog() {
|
||||
return L.resolveDefault(callLog(500), null).then(function(res) {
|
||||
return (res && res.text) || '';
|
||||
});
|
||||
}
|
||||
|
||||
// gitbackup -- shared bounded live-log poller (ticket 23, spec "Не
|
||||
// изобретать четвёртый механизм"). Before this file existed, overview.js
|
||||
// (run/test), history.js (restore) and settings.js (test) each carried
|
||||
// their own, separately-typed copy of the exact same state machine: fetch
|
||||
// a log tail, diff it against the line count from the previous tick, feed
|
||||
// the new lines to a caller-supplied terminal-line regex, and give up
|
||||
// after 60s of no new output or a hard 5-minute ceiling regardless --
|
||||
// interfaces.md's own record of tickets 11-14 already names this bound
|
||||
// three times over ("терминальная строка лога, простой в 60 секунд,
|
||||
// жёсткий потолок в 5 минут"), copied rather than shared. Ticket 23's own
|
||||
// acceptance criterion ("механизм поллинга один на все три view, а не
|
||||
// четыре разных") asks for exactly one implementation of that shape; this
|
||||
// is it.
|
||||
//
|
||||
// LuCI's own require() resolves a module ONCE per page and hands back the
|
||||
// same already-instantiated singleton to every caller (luci.js:
|
||||
// `compileClass`'s `new _class()`, cached in `classes[name]`) -- so every
|
||||
// one of the three views above sharing this same instance is safe only
|
||||
// because each of them is its own full page load (classic multi-page LuCI
|
||||
// tears down the whole JS context on tab switch) AND never runs two such
|
||||
// pollers from itself at once (the button(s) a poller was started from
|
||||
// stay disabled for its entire run). start() below resets every piece of
|
||||
// this instance's state, so a second, later operation on the very same
|
||||
// page (e.g. overview.js's "Backup now" followed later by its own "Test
|
||||
// connection") reuses the same instance cleanly once the first one has
|
||||
// actually stopped.
|
||||
return L.Class.extend({
|
||||
// start(opts) -- begins polling opts.fetch() every opts.interval
|
||||
// seconds (default 2, matching every prior copy of this poller).
|
||||
// opts.fetch() must resolve to the log's current text tail (a
|
||||
// rejected fetch is treated as "no output this tick", same as an
|
||||
// empty string, and repeated failures alone can still trip the idle
|
||||
// bound below). Returns a promise that resolves once the FIRST fetch
|
||||
// (establishing the starting line count) has completed and polling
|
||||
// has actually started -- callers await this before firing the
|
||||
// backgrounded CLI call itself, exactly like every prior copy of this
|
||||
// poller did, so a very fast operation's own first output line is
|
||||
// never missed.
|
||||
//
|
||||
// opts.terminalRe (RegExp, required) -- tested against each newly
|
||||
// arrived line; a match stops the poller and fires opts.onFinish.
|
||||
// opts.onProgress(addedText), if given, fires on every tick that saw
|
||||
// new output at all, with only the lines new since the previous tick
|
||||
// (already newline-joined, blank lines dropped) -- never the whole
|
||||
// accumulated text, so a caller appending it to a growing <pre> never
|
||||
// duplicates a line.
|
||||
// opts.onFinish(matchedLine), if given, fires exactly once, after the
|
||||
// poller has already stopped itself, with the one line that matched
|
||||
// opts.terminalRe.
|
||||
// opts.onTimeout(), if given, fires exactly once, after the poller has
|
||||
// already stopped itself, when opts.idleLimit ticks (default 30 == 60s
|
||||
// at the default 2s interval) passed with no new output at all, when
|
||||
// opts.tickLimit ticks (default 150 == 5 minutes) is reached
|
||||
// regardless, or when opts.fetch() itself keeps rejecting for that
|
||||
// same idle span -- three different reasons this project's own
|
||||
// interfaces.md has always treated as one bound ("простой... либо
|
||||
// потолок"), not three outcomes a caller has to tell apart.
|
||||
start: function(opts) {
|
||||
callLog: callLog,
|
||||
|
||||
// Resolves only once the operation is over, so a createHandlerFn button
|
||||
// stays disabled until then. Exactly one of opts.onFinish/onTimeout/onFail
|
||||
// fires.
|
||||
run: function(opts) {
|
||||
var self = this;
|
||||
var pre = opts.pre ? document.getElementById(opts.pre) : null;
|
||||
|
||||
self._opts = opts;
|
||||
self._idle = 0;
|
||||
self._ticks = 0;
|
||||
self._lines = 0;
|
||||
self._done = false;
|
||||
|
||||
return opts.fetch().then(function(text) {
|
||||
text = text || '';
|
||||
self._lines = text ? text.split('\n').length : 0;
|
||||
if (pre) {
|
||||
pre.hidden = false;
|
||||
pre.textContent = '';
|
||||
}
|
||||
|
||||
if (!self._bound)
|
||||
self._bound = L.bind(self._tick, self);
|
||||
return new Promise(function(resolve) {
|
||||
self._opts = opts;
|
||||
self._resolve = resolve;
|
||||
|
||||
poll.remove(self._bound);
|
||||
poll.add(self._bound, opts.interval || 2);
|
||||
// Baseline before the call, or the operation's first line is missed.
|
||||
gbFetchLog().then(function(text) {
|
||||
self._lines = text ? text.split('\n').length : 0;
|
||||
|
||||
if (!self._bound)
|
||||
self._bound = L.bind(self._tick, self);
|
||||
|
||||
poll.remove(self._bound);
|
||||
poll.add(self._bound, 2);
|
||||
|
||||
return opts.call();
|
||||
}).then(function(res) {
|
||||
if (!res || res.started !== true)
|
||||
throw new Error((res && res.reason) || '');
|
||||
}).catch(function(e) {
|
||||
self._end(opts.onFail, e.message);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// stop() -- idempotent; safe to call whether or not a poll is
|
||||
// currently running (every call site here does, on the "could not
|
||||
// even start the operation" path, whether or not start() got as far
|
||||
// as poll.add).
|
||||
stop: function() {
|
||||
if (this._bound)
|
||||
poll.remove(this._bound);
|
||||
},
|
||||
|
||||
_append: function(add) {
|
||||
var pre = this._opts.pre ? document.getElementById(this._opts.pre) : null;
|
||||
|
||||
if (!pre)
|
||||
return;
|
||||
|
||||
pre.textContent = pre.textContent ? pre.textContent + '\n' + add : add;
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
},
|
||||
|
||||
_end: function(cb, arg) {
|
||||
if (this._done)
|
||||
return;
|
||||
this._done = true;
|
||||
this.stop();
|
||||
if (cb)
|
||||
cb(arg);
|
||||
this._resolve();
|
||||
},
|
||||
|
||||
_tick: function() {
|
||||
var self = this;
|
||||
var opts = self._opts;
|
||||
var idleLimit = opts.idleLimit || 30;
|
||||
var tickLimit = opts.tickLimit || 150;
|
||||
|
||||
self._ticks++;
|
||||
|
||||
return opts.fetch().then(function(text) {
|
||||
var lines, newLines, add, finished, matchedLine, i;
|
||||
|
||||
text = text || '';
|
||||
lines = text.split('\n');
|
||||
newLines = (lines.length >= self._lines) ? lines.slice(self._lines) : lines;
|
||||
add = newLines.filter(function(l) { return l; }).join('\n');
|
||||
finished = false;
|
||||
matchedLine = null;
|
||||
return callLog(500).then(function(res) {
|
||||
var lines = ((res && res.text) || '').split('\n');
|
||||
var newLines = (lines.length >= self._lines) ? lines.slice(self._lines) : lines;
|
||||
var add = newLines.filter(function(l) { return l; }).join('\n');
|
||||
var matched = null;
|
||||
var i;
|
||||
|
||||
self._lines = lines.length;
|
||||
|
||||
if (add) {
|
||||
self._idle = 0;
|
||||
if (opts.onProgress)
|
||||
opts.onProgress(add);
|
||||
self._append(add);
|
||||
for (i = 0; i < newLines.length; i++) {
|
||||
if (opts.terminalRe.test(newLines[i])) {
|
||||
finished = true;
|
||||
matchedLine = newLines[i];
|
||||
}
|
||||
if (opts.terminalRe.test(newLines[i]))
|
||||
matched = newLines[i];
|
||||
}
|
||||
} else {
|
||||
self._idle++;
|
||||
}
|
||||
|
||||
if (finished) {
|
||||
self.stop();
|
||||
if (opts.onFinish)
|
||||
opts.onFinish(matchedLine);
|
||||
} else if (self._idle >= idleLimit || self._ticks >= tickLimit) {
|
||||
self.stop();
|
||||
if (opts.onTimeout)
|
||||
opts.onTimeout();
|
||||
}
|
||||
if (matched !== null)
|
||||
self._end(opts.onFinish, matched);
|
||||
else if (self._idle >= 30 || self._ticks >= 150)
|
||||
self._end(opts.onTimeout);
|
||||
}, function() {
|
||||
// A single failed poll must not spin forever either -- same
|
||||
// convention every prior copy of this poller used (count it
|
||||
// as a big idle jump rather than a distinct error path).
|
||||
self._idle += idleLimit;
|
||||
if (self._idle >= idleLimit) {
|
||||
self.stop();
|
||||
if (opts.onTimeout)
|
||||
opts.onTimeout();
|
||||
}
|
||||
self._end(opts.onTimeout);
|
||||
});
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,91 +2,12 @@
|
||||
'require view';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
'require fs';
|
||||
'require view.gitbackup.common as common';
|
||||
/* global common */
|
||||
|
||||
// gitbackup -- Paths (ticket 13, spec "Решения по реализации -> LuCI -> Paths",
|
||||
// "Сбор и manifest.json", "Проверенные факты 25.12.4 -> sysupgrade"). Client-side
|
||||
// JS only, luci-base only -- same constraint and the same jsmin-conservative
|
||||
// style (plain ES5, `var`/`function`, no let/const/arrow/template literals,
|
||||
// every statement semicolon-terminated) as overview.js (ticket 11), read
|
||||
// before writing this file.
|
||||
//
|
||||
// The whole point of this screen (spec/brief, verbatim): "Расширение списка --
|
||||
// редактирование /etc/sysupgrade.conf, а не отдельный UCI-список. То, что
|
||||
// пользователь добавил ради бэкапа, автоматически переживёт реальный
|
||||
// sysupgrade." That file is owned entirely by package/gitbackup's paths.sh and
|
||||
// this file's own rpcd bridge (usr/libexec/rpcd/luci.gitbackup) -- both out of
|
||||
// this ticket's own zone in the original cut, but the read/write asymmetry
|
||||
// documented below was later closed in that same package/gitbackup zone, and
|
||||
// this view was updated alongside it.
|
||||
//
|
||||
// ---------------------------------------------------------------------
|
||||
// Raw vs. entries vs. effective -- the defect this file used to work
|
||||
// around instead of fixing, closed by making `list_paths` carry three
|
||||
// sets explicitly:
|
||||
//
|
||||
// - `paths` -- the RAW, unexpanded content of /etc/sysupgrade.conf
|
||||
// (gb_paths_list), comments and blank lines included.
|
||||
// Not used by this view at all (see `entries` below).
|
||||
// - `entries` -- (ticket 26) `paths`, filtered down to genuine path
|
||||
// entries: comments (sysupgrade.conf's own syntax --
|
||||
// a line starting with '#', including a commented-out
|
||||
// EXAMPLE such as "# /etc/openvpn/", which backs up
|
||||
// nothing) and blank lines are dropped by
|
||||
// gb_paths_entries server-side. This is what this view
|
||||
// lists and edits, and the only thing `set_paths` ever
|
||||
// writes back -- a directory line stays a directory
|
||||
// line across any number of edits, so anything added
|
||||
// to it later keeps being covered by a real sysupgrade
|
||||
// the same way it always was, instead of being frozen
|
||||
// into the exact file list that happened to exist on
|
||||
// the day of the last save here. Before this field
|
||||
// existed, this view built its editable list straight
|
||||
// from `paths`, so a stock router's own header
|
||||
// comments and example lines rendered as four bogus,
|
||||
// removable entries -- and the first save from this
|
||||
// view (any Add or Remove click) silently erased them,
|
||||
// because `set_paths` used to write back only what it
|
||||
// was sent and never looked at the rest of the file.
|
||||
// That erasure is now prevented server-side
|
||||
// (gb_paths_replace_entries, paths.sh) rather than by
|
||||
// this view trying to resend lines it was never shown
|
||||
// in the first place -- see that function's own header
|
||||
// comment for why the fix belongs there.
|
||||
// - `effective` -- plain `sysupgrade -l`'s own wider union (spec:
|
||||
// "(/etc/sysupgrade.conf ∪ /lib/upgrade/keep.d/*) ∪
|
||||
// изменённые conffiles"), already recursively expanded
|
||||
// to individual files/symlinks. Read-only: used only
|
||||
// to size the real backup set (the 2 MB warning has to
|
||||
// mean what `sysupgrade -b` will actually build, not
|
||||
// just the lines a human typed) and to show what is
|
||||
// already covered without anyone having added it here.
|
||||
//
|
||||
// Earlier, `list_paths` returned only the effective set under the name
|
||||
// `paths`, so this view's own edits were built on top of the ALREADY
|
||||
// EXPANDED list -- the first save after a human added a directory line
|
||||
// via the CLI would write that directory's individual files straight
|
||||
// back to sysupgrade.conf, permanently losing the one-line-per-directory
|
||||
// convenience. Editing the raw set instead removes the problem instead of
|
||||
// working around it.
|
||||
//
|
||||
// A second, related gap this same change closes: `set_paths`'s own
|
||||
// server-side check used to duplicate only two of paths.sh's
|
||||
// gb_paths_validate rules (absolute, the fixed blacklist), leaving a
|
||||
// space in the path and a nonexistent path to this view's own JS to
|
||||
// catch -- which could never reliably tell a dangling symlink (a
|
||||
// legitimate entry, e.g. /etc/resolv.conf) from a genuinely missing path,
|
||||
// because no rpcd ACL anywhere grants `fs.lstat` (confirmed live: every
|
||||
// attempt at fs.lstat here failed with rpcd's own -32002 "Access denied",
|
||||
// browser console, root login included -- only `fs.stat` is reachable).
|
||||
// `set_paths` now routes every entry through gb_paths_validate itself,
|
||||
// which runs ON the router and sees the real filesystem, so it gets `-e
|
||||
// || -L` right without needing that ACL grant at all. This view keeps its
|
||||
// own quick client-side checks (gbPathReject below) as a fast first pass
|
||||
// that avoids an obviously-doomed round trip, but the round trip -- and
|
||||
// its `rejected[]` response -- is the actual, authoritative gate; this
|
||||
// view no longer attempts its own existence check at all.
|
||||
// ---------------------------------------------------------------------
|
||||
// Writes back `entries`, never `effective` (sysupgrade -l): a directory line
|
||||
// must stay a directory line, or files created after the save stop being
|
||||
// backed up. Path validation lives in paths.sh only.
|
||||
|
||||
var callListPaths = rpc.declare({
|
||||
object: 'luci.gitbackup',
|
||||
@@ -104,127 +25,16 @@ var callSetPaths = rpc.declare({
|
||||
params: [ 'paths' ]
|
||||
});
|
||||
|
||||
// gb_paths_size_kb (paths.sh) rounds bytes up to a whole KB: awk's
|
||||
// `(s + 1023) / 1024` truncated to an integer is exactly `ceil(s / 1024)`.
|
||||
// No rpcd method exposes that number directly, so this view derives the
|
||||
// same total the same way paths.sh does: sum the byte size of every entry
|
||||
// in `list_paths`'s "effective" set -- the same full set `sysupgrade -l`
|
||||
// (and `run`'s own pre-flight space check) actually measures, not just
|
||||
// the raw lines a human typed -- using `fs.stat`, deliberately not
|
||||
// `fs.lstat`, and not only because of the ACL note above. gb_paths_size_kb's
|
||||
// own test is plain `[ -f "$path" ]`, which -- confirmed live against this
|
||||
// stand's busybox ash (a symlink to a regular file passes `-f`) -- FOLLOWS
|
||||
// a symlink to its target, same as `fs.stat`; only a target that is not a
|
||||
// regular file (a directory, or a dangling symlink) makes `-f` false and
|
||||
// the entry contributes 0. Using `fs.lstat` here would size the symlink's
|
||||
// own few bytes instead of its target's real content and disagree with
|
||||
// the shell-side total for the exact same set.
|
||||
var GB_PATHS_WARN_KB = 2048;
|
||||
|
||||
// gbPathReject <path> -- null when the path is worth submitting to
|
||||
// `set_paths`, otherwise one human-readable reason, worded identically to
|
||||
// package/gitbackup's paths.sh (gb_paths_validate) for the checks that
|
||||
// module owns and that do not need a filesystem round trip: absolute, no
|
||||
// space, not in the fixed blacklist. Order matches gb_paths_validate's own
|
||||
// (absolute first, everything else assumes it). This is a fast first pass
|
||||
// only -- `set_paths` re-validates every entry server-side (gb_paths_validate
|
||||
// itself, including existence) and is the actual gate; a path that slips
|
||||
// past this function is still caught there and reported through
|
||||
// commitPaths's own `rejected[]` handling below.
|
||||
function gbPathReject(path) {
|
||||
if (path.charAt(0) !== '/')
|
||||
return _('%s: not an absolute path').format(path);
|
||||
|
||||
if (path.indexOf(' ') !== -1)
|
||||
return _('%s: contains a space -- sysupgrade.conf lines become find(1) arguments and cannot quote one').format(path);
|
||||
|
||||
if (path === '/etc/gitbackup' || path.indexOf('/etc/gitbackup/') === 0)
|
||||
return _('%s: reserved for gitbackup itself').format(path);
|
||||
|
||||
if (path === '/proc' || path.indexOf('/proc/') === 0)
|
||||
return _('%s: not a real filesystem path').format(path);
|
||||
|
||||
if (path === '/sys' || path.indexOf('/sys/') === 0)
|
||||
return _('%s: not a real filesystem path').format(path);
|
||||
|
||||
if (path === '/tmp' || path.indexOf('/tmp/') === 0)
|
||||
return _('%s: cleared on every reboot').format(path);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// gbSequentialMap <items[]> <fn> -- like items.map(fn), but runs one call at
|
||||
// a time and only starts the next once the previous settles, instead of
|
||||
// firing every call at once. Confirmed necessary live on the owlab stand:
|
||||
// stock `uhttpd` there runs with `-n 3` (three concurrent ubus-bridge
|
||||
// requests, the same limit a real low-memory router ships with, not
|
||||
// something this dev stand loosened), and firing one `fs.stat` per entry of
|
||||
// even the 13-line clean-image default set at once left every later ubus
|
||||
// call queued behind it indefinitely -- confirmed by watching the network
|
||||
// panel: no request for a later call was even sent until an earlier one's
|
||||
// slot freed, and with enough of them in flight the rest of the page (this
|
||||
// view's own next list_paths/set_paths call, even an unrelated view) stayed
|
||||
// stuck on "Loading view..." until the batch drained. `Promise.all` over
|
||||
// every path was the original, incorrect implementation.
|
||||
function gbSequentialMap(items, fn) {
|
||||
var results = [];
|
||||
var i = 0;
|
||||
|
||||
function next() {
|
||||
if (i >= items.length)
|
||||
return Promise.resolve(results);
|
||||
|
||||
return fn(items[i]).then(function(res) {
|
||||
results.push(res);
|
||||
i++;
|
||||
return next();
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
// gbSumSizes <paths[]> -- { kb, unknown }. `unknown` counts entries whose
|
||||
// size could not be read at all (permission, or the entry vanished between
|
||||
// `list_paths` answering and this running) so the total is never silently
|
||||
// wrong without saying so. Called with the "effective" set, never the raw
|
||||
// one -- see the file-header comment.
|
||||
function gbSumSizes(paths) {
|
||||
return gbSequentialMap(paths, function(p) {
|
||||
return fs.stat(p).then(function(st) { return st; }, function() { return null; });
|
||||
}).then(function(stats) {
|
||||
var bytes = 0;
|
||||
var unknown = 0;
|
||||
var i;
|
||||
|
||||
for (i = 0; i < stats.length; i++) {
|
||||
if (stats[i] && stats[i].type === 'file')
|
||||
bytes += stats[i].size || 0;
|
||||
else if (!stats[i])
|
||||
unknown++;
|
||||
}
|
||||
|
||||
return { kb: Math.ceil(bytes / 1024), unknown: unknown };
|
||||
});
|
||||
}
|
||||
|
||||
// gbCoveredBy <rawEntry> <effectivePath> -- true when <effectivePath> is
|
||||
// exactly <rawEntry>, or lies underneath it as a directory. <rawEntry>'s
|
||||
// own trailing slash (if any -- sysupgrade.conf directory lines commonly
|
||||
// carry one) is normalized away first so both spellings of the same
|
||||
// directory match the same children.
|
||||
// sysupgrade.conf directory lines commonly carry a trailing slash.
|
||||
function gbCoveredBy(rawEntry, effectivePath) {
|
||||
var prefix = rawEntry.replace(/\/+$/, '') + '/';
|
||||
return effectivePath === rawEntry || effectivePath.indexOf(prefix) === 0;
|
||||
}
|
||||
|
||||
// gbAutomaticEntries <rawPaths[]> <effectivePaths[]> -- the "effective" set
|
||||
// entries not explained by anything a human put in the raw list (not equal
|
||||
// to a raw entry, not a descendant of a raw directory entry) -- i.e. only
|
||||
// there because /lib/upgrade/keep.d/* or a changed package conffile
|
||||
// already covers it. Shown read-only, separately from the editable raw
|
||||
// list, so the operator can see the difference between "I added this" and
|
||||
// "this is already covered without me doing anything".
|
||||
// Effective entries no raw line explains: kept by /lib/upgrade/keep.d or a
|
||||
// changed package conffile without the operator adding them.
|
||||
function gbAutomaticEntries(rawPaths, effectivePaths) {
|
||||
return effectivePaths.filter(function(p) {
|
||||
var i;
|
||||
@@ -264,21 +74,12 @@ function gbBuildPathsList(self, paths) {
|
||||
}));
|
||||
}
|
||||
|
||||
function gbBuildSizeBox(kb, unknown) {
|
||||
var children = [];
|
||||
var text;
|
||||
function gbBuildSizeBox(kb) {
|
||||
var children = [
|
||||
E('p', { 'class': 'gitbackup-size' }, [ _('Total size of the backup set: %d KB').format(kb) ])
|
||||
];
|
||||
|
||||
if (kb == null)
|
||||
text = _('Calculating the backup set size…');
|
||||
else
|
||||
text = _('Total size of the backup set: %d KB').format(kb);
|
||||
|
||||
if (unknown)
|
||||
text += ' ' + _('(some entries could not be measured and are left out of this total)');
|
||||
|
||||
children.push(E('p', { 'class': 'gitbackup-size' }, [ text ]));
|
||||
|
||||
if (kb != null && kb > GB_PATHS_WARN_KB) {
|
||||
if (kb > GB_PATHS_WARN_KB) {
|
||||
children.push(E('div', { 'class': 'gitbackup-warn' }, [
|
||||
E('p', {}, [ _('This is over 2 MB. A real "sysupgrade -b" builds this archive entirely in RAM, and past this size it can fail on a low-memory device. This is only a warning -- nothing here is blocked, trim the list above if that matters on this device.') ])
|
||||
]));
|
||||
@@ -330,11 +131,7 @@ function gbBuildAuditBox(self, audit) {
|
||||
}
|
||||
|
||||
var GB_CSS = [
|
||||
'.gitbackup-view { container-type: inline-size; }',
|
||||
'.gitbackup-caption { color: var(--text-color-medium, #666); }',
|
||||
'.gitbackup-actions { display: flex; flex-wrap: wrap; gap: .5em; margin: .75em 0; align-items: center; }',
|
||||
'.gitbackup-actions input[type="text"] { flex: 1 1 260px; min-width: 200px; }',
|
||||
'.gitbackup-card-hint { font-size: .9em; color: var(--text-color-medium, #666); }',
|
||||
'.gitbackup-path-list { list-style: none; margin: .5em 0; padding: 0; }',
|
||||
'.gitbackup-path-row { display: flex; align-items: center; justify-content: space-between; gap: .75em; padding: .4em .6em; border-bottom: 1px solid var(--background-color-medium, #ddd); }',
|
||||
'.gitbackup-path-row:last-child { border-bottom: none; }',
|
||||
@@ -346,9 +143,6 @@ var GB_CSS = [
|
||||
];
|
||||
|
||||
return view.extend({
|
||||
// Not a config form -- see overview.js's own identical comment. Rule 11
|
||||
// of the spec's style guide: the stock Save/Apply/Reset row is switched
|
||||
// off through this documented hook, never hidden with CSS.
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null,
|
||||
@@ -360,25 +154,25 @@ return view.extend({
|
||||
]);
|
||||
},
|
||||
|
||||
applyData: function(data) {
|
||||
this._paths = (data[0] && data[0].entries) || [];
|
||||
this._effective = (data[0] && data[0].effective) || [];
|
||||
this._sizeKb = (data[0] && data[0].size_kb) || 0;
|
||||
this._audit = (data[1] && data[1].paths) || [];
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var self = this;
|
||||
var view;
|
||||
|
||||
self._paths = (data[0] && data[0].entries) || [];
|
||||
self._effective = (data[0] && data[0].effective) || [];
|
||||
self._audit = (data[1] && data[1].paths) || [];
|
||||
self._sizeKb = null;
|
||||
self._sizeUnknown = 0;
|
||||
self.applyData(data);
|
||||
self._body = E('div');
|
||||
|
||||
view = E('div', { 'class': 'gitbackup-view' }, [
|
||||
E('style', { 'type': 'text/css' }, [ GB_CSS.join('\n') ]),
|
||||
E('style', { 'type': 'text/css' }, [ common.css.concat(GB_CSS).join('\n') ]),
|
||||
|
||||
E('h2', {}, [ _('Git Backup - Paths') ]),
|
||||
|
||||
// Spec, verbatim: the caption is itself a feature, not a footnote
|
||||
// -- this edits the same file the router's own firmware-upgrade
|
||||
// tool reads, so anything listed here survives a real sysupgrade
|
||||
// on its own, whether or not gitbackup itself ever runs again.
|
||||
E('p', { 'class': 'gitbackup-caption' },
|
||||
[ _('These paths also survive a firmware upgrade: they are written straight into /etc/sysupgrade.conf, the same file the router\'s own sysupgrade reads, not a separate list of this tool\'s own. A directory you add here stays a directory -- anything added to it later is covered too, exactly like a real sysupgrade would.') ]),
|
||||
|
||||
@@ -402,66 +196,34 @@ return view.extend({
|
||||
}, [ _('Add path') ])
|
||||
]),
|
||||
|
||||
E('div', { 'id': 'gitbackup-paths-body' })
|
||||
self._body
|
||||
]);
|
||||
|
||||
self.renderBody();
|
||||
self.refreshSizes();
|
||||
|
||||
return view;
|
||||
},
|
||||
|
||||
// Rebuilds the size summary, editable path list, automatic-coverage box
|
||||
// and audit box from self._paths/self._effective/self._audit -- called
|
||||
// after every load and every successful edit (commitPaths/refresh
|
||||
// below). Small N (13 keep.d entries on a clean stand plus whatever has
|
||||
// been added), so a full rebuild rather than per-row DOM patching is
|
||||
// simple and cheap enough.
|
||||
renderBody: function() {
|
||||
var self = this;
|
||||
var body = document.getElementById('gitbackup-paths-body');
|
||||
var automatic;
|
||||
|
||||
if (!body)
|
||||
return;
|
||||
|
||||
automatic = gbAutomaticEntries(self._paths, self._effective);
|
||||
// A node reference, not getElementById: render() calls this before
|
||||
// its tree is attached to the document.
|
||||
var body = self._body;
|
||||
var automatic = gbAutomaticEntries(self._paths, self._effective);
|
||||
|
||||
body.textContent = '';
|
||||
body.appendChild(gbBuildSizeBox(self._sizeKb, self._sizeUnknown));
|
||||
body.appendChild(gbBuildSizeBox(self._sizeKb));
|
||||
body.appendChild(gbBuildPathsList(self, self._paths));
|
||||
body.appendChild(gbBuildAutomaticBox(automatic));
|
||||
body.appendChild(gbBuildAuditBox(self, self._audit));
|
||||
},
|
||||
|
||||
refreshSizes: function() {
|
||||
var self = this;
|
||||
var effective = self._effective;
|
||||
|
||||
return gbSumSizes(effective).then(function(res) {
|
||||
// A slower stat batch landing after the effective set moved on
|
||||
// (another edit already completed) would show a total for a
|
||||
// set that is no longer displayed -- discard it instead.
|
||||
if (self._effective !== effective)
|
||||
return;
|
||||
self._sizeKb = res.kb;
|
||||
self._sizeUnknown = res.unknown;
|
||||
self.renderBody();
|
||||
});
|
||||
},
|
||||
|
||||
refresh: function() {
|
||||
var self = this;
|
||||
|
||||
return Promise.all([
|
||||
L.resolveDefault(callListPaths(), null),
|
||||
L.resolveDefault(callAuditPaths(), null)
|
||||
]).then(function(data) {
|
||||
self._paths = (data[0] && data[0].entries) || [];
|
||||
self._effective = (data[0] && data[0].effective) || [];
|
||||
self._audit = (data[1] && data[1].paths) || [];
|
||||
return self.load().then(function(data) {
|
||||
self.applyData(data);
|
||||
self.renderBody();
|
||||
return self.refreshSizes();
|
||||
});
|
||||
},
|
||||
|
||||
@@ -475,14 +237,7 @@ return view.extend({
|
||||
btn.disabled = busy;
|
||||
},
|
||||
|
||||
// commitPaths <newList> [addedPath] -- the one path to `set_paths`.
|
||||
// Reports every backend-side rejection by name and reason (spec: "не
|
||||
// молча"), then always refreshes from `list_paths` regardless of outcome
|
||||
// -- the effective set on disk is the only thing worth trusting after a
|
||||
// write, never this view's own optimistic guess at what changed.
|
||||
// Resolves to true only when `addedPath` (if given) was not among the
|
||||
// rejected entries, so callers adding a single new path know whether to
|
||||
// also clear the input field.
|
||||
// Resolves false when addedPath itself was rejected.
|
||||
commitPaths: function(newList, addedPath) {
|
||||
var self = this;
|
||||
|
||||
@@ -492,8 +247,8 @@ return view.extend({
|
||||
var i;
|
||||
|
||||
for (i = 0; i < rejected.length; i++) {
|
||||
ui.addNotification(null, E('p', {},
|
||||
[ _('%s: %s').format(rejected[i].path, rejected[i].reason) ]), 'error');
|
||||
// The reason already starts with the path.
|
||||
ui.addNotification(null, E('p', {}, [ rejected[i].reason ]), 'error');
|
||||
if (addedPath && rejected[i].path === addedPath)
|
||||
ok = false;
|
||||
}
|
||||
@@ -506,18 +261,10 @@ return view.extend({
|
||||
});
|
||||
},
|
||||
|
||||
// handleAddPath -- only the fast, local checks (non-empty, not already
|
||||
// present, gbPathReject's syntax-only rules) happen before this ever
|
||||
// calls `set_paths`. Existence is deliberately NOT checked here: it
|
||||
// needs a real filesystem, `set_paths` already checks it authoritatively
|
||||
// server-side (gb_paths_validate), and a client-side attempt at the same
|
||||
// question would need `fs.lstat`, which no rpcd ACL here grants -- see
|
||||
// the file-header comment.
|
||||
handleAddPath: function(ev) {
|
||||
var self = this;
|
||||
var input = document.getElementById('gitbackup-paths-add-input');
|
||||
var path = input ? input.value.replace(/^\s+|\s+$/g, '') : '';
|
||||
var reason;
|
||||
var path = input ? input.value.trim() : '';
|
||||
|
||||
if (!path) {
|
||||
ui.addNotification(null, E('p', {}, [ _('Enter a path first.') ]), 'error');
|
||||
@@ -529,12 +276,6 @@ return view.extend({
|
||||
return;
|
||||
}
|
||||
|
||||
reason = gbPathReject(path);
|
||||
if (reason) {
|
||||
ui.addNotification(null, E('p', {}, [ reason ]), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
self.setAddBusy(true);
|
||||
|
||||
return self.commitPaths(self._paths.concat([ path ]), path).then(function(ok) {
|
||||
@@ -544,19 +285,12 @@ return view.extend({
|
||||
});
|
||||
},
|
||||
|
||||
handleAddFromAudit: function(ev, path) {
|
||||
var self = this;
|
||||
var reason = gbPathReject(path);
|
||||
|
||||
if (reason) {
|
||||
ui.addNotification(null, E('p', {}, [ reason ]), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
return self.commitPaths(self._paths.concat([ path ]), path);
|
||||
// createHandlerFn passes bound args first, the event last.
|
||||
handleAddFromAudit: function(path, ev) {
|
||||
return this.commitPaths(this._paths.concat([ path ]), path);
|
||||
},
|
||||
|
||||
handleRemovePath: function(ev, path) {
|
||||
handleRemovePath: function(path, ev) {
|
||||
var self = this;
|
||||
var idx = self._paths.indexOf(path);
|
||||
var next;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user