mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-26 23:32:51 +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
@@ -1,39 +0,0 @@
|
||||
#
|
||||
# Copyright (C) 2026 VizzleTF
|
||||
#
|
||||
# This is free software, licensed under the GNU General Public License v2.
|
||||
#
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-gitbackup
|
||||
PKG_VERSION:=0.1.1
|
||||
PKG_RELEASE:=1
|
||||
|
||||
PKG_LICENSE:=GPL-2.0-only
|
||||
PKG_MAINTAINER:=VizzleTF <vizzlef@gmail.com>
|
||||
|
||||
LUCI_TITLE:=Automatic configuration backup to a Git repository
|
||||
# gitbackup itself (package/gitbackup) is where usr/sbin/gitbackup, the
|
||||
# rpcd plugin and every shell module live -- this app is client-side JS
|
||||
# only (ticket 11 acceptance criterion: "ни строки Lua, ни luci-compat, ни
|
||||
# luci-lib-ipkg -- ни в коде, ни в зависимостях") and has nothing to render
|
||||
# without it.
|
||||
LUCI_DEPENDS:=+gitbackup
|
||||
# Every byte here is POSIX shell (menu.d/acl.d JSON is not shell, but is not
|
||||
# compiled either), plain JSON and client-side JS -- not one compiled byte,
|
||||
# same reasoning as package/gitbackup's own PKGARCH:=all (spec, "Упаковка").
|
||||
# LUCI_PKGARCH already defaults to "all" via luci.mk's own
|
||||
# `LUCI_PKGARCH?=$(if $(realpath src/Makefile),,all)` (no src/Makefile here),
|
||||
# but ticket 11's acceptance criterion asks for it spelled out explicitly.
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
# This repository is a standalone third-party app, not a checkout living
|
||||
# inside the openwrt/luci monorepo (unlike e.g. applications/luci-app-ttyd,
|
||||
# which can say `include $(TOPDIR)/feeds/luci/luci.mk is two
|
||||
# directories up from it there) -- so the helper has to be referenced by
|
||||
# its real location in a buildroot that has the "luci" feed checked out,
|
||||
# exactly as spec "Упаковка" names it.
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
# call BuildPackage - OpenWrt buildroot signature
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user