mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 05:12:51 +08:00
1065 lines
43 KiB
JavaScript
1065 lines
43 KiB
JavaScript
'use strict';
|
|
'require view';
|
|
'require poll';
|
|
'require rpc';
|
|
'require uci';
|
|
'require ui';
|
|
'require view.gitbackup.diffview as diffview';
|
|
'require view.gitbackup.oplog as oplog';
|
|
/* global diffview, oplog */
|
|
// The eslint config this project lints against (tools/fetch-eslint-config.sh,
|
|
// openwrt/luci's own eslint.config.mjs) hardcodes `readonly` globals for a
|
|
// fixed list of stock LuCI module names ("poll", "rpc", "ui", ...) -- it has
|
|
// no way to know about an app's own custom module aliases, so it flags
|
|
// `diffview`/`oplog` above as no-undef otherwise. Declaring them here is the
|
|
// standard ESLint mechanism for exactly this case, scoped to this file only.
|
|
|
|
// gitbackup -- Overview (ticket 11, spec "Решения по реализации -> LuCI ->
|
|
// Overview"). Client-side JS only, luci-base only -- no Lua, no
|
|
// luci-compat, no luci-lib-ipkg anywhere in this file or in its
|
|
// dependencies (LUCI_DEPENDS:=+gitbackup only).
|
|
//
|
|
// Written conservatively for OpenWrt buildbot's own jsmin.c (spec: "старый
|
|
// минификатор, не понимает современного синтаксиса, чувствителен к
|
|
// отсутствию точек с запятой") -- plain ES5 (`var`, `function`), no
|
|
// let/const, no arrow functions, no template literals, every statement
|
|
// terminated with an explicit semicolon.
|
|
|
|
// ---------------------------------------------------------------------
|
|
// rpcd bindings -- every object/method/param name below is copied from
|
|
// root/usr/share/rpcd/acl.d/luci-app-gitbackup.json and from
|
|
// usr/libexec/rpcd/luci.gitbackup's own gbrpc_* handlers (package/
|
|
// gitbackup). Nothing invented: `history`, `log`, `run`, `test`, `card`
|
|
// and `config_diff` are exactly the read methods and one of the two
|
|
// write methods this view uses. `config_diff` (gbrpc_config_diff) is a
|
|
// thin JSON wrapper around `gitbackup diff` (usr/sbin/gitbackup's own
|
|
// cmd_diff) -- it re-collects the current backup set and compares its
|
|
// manifest against the last commit's, field by field, so it catches a
|
|
// chmod/chown `git status`/`git diff` never sees at all (same blob, same
|
|
// tree entry mode class either way).
|
|
// ---------------------------------------------------------------------
|
|
|
|
var callStatus = rpc.declare({
|
|
object: 'luci.gitbackup',
|
|
method: 'status'
|
|
});
|
|
|
|
var callLog = rpc.declare({
|
|
object: 'luci.gitbackup',
|
|
method: 'log',
|
|
params: [ 'lines' ]
|
|
});
|
|
|
|
var callHistory = rpc.declare({
|
|
object: 'luci.gitbackup',
|
|
method: 'history',
|
|
params: [ 'limit' ]
|
|
});
|
|
|
|
var callConfigDiff = rpc.declare({
|
|
object: 'luci.gitbackup',
|
|
method: 'config_diff'
|
|
});
|
|
|
|
var callValidateCron = rpc.declare({
|
|
object: 'luci.gitbackup',
|
|
method: 'validate_cron',
|
|
params: [ 'expr' ]
|
|
});
|
|
|
|
var callRun = rpc.declare({
|
|
object: 'luci.gitbackup',
|
|
method: 'run'
|
|
});
|
|
|
|
var callTest = rpc.declare({
|
|
object: 'luci.gitbackup',
|
|
method: 'test'
|
|
});
|
|
|
|
var callCard = rpc.declare({
|
|
object: 'luci.gitbackup',
|
|
method: 'card'
|
|
});
|
|
|
|
// service.list is procd's own stock ubus object, not part of this
|
|
// package's ACL declaration -- the same call luci-app-frpc/frps/natmap/
|
|
// irqbalance/transmission/... already make for their own "is my daemon
|
|
// running" checks (confirmed against those views in this checkout), and
|
|
// every LuCI session already has read access to it. Used only to answer
|
|
// the "crond stopped" banner (spec: "Красные баннеры: ... crond не
|
|
// запущен"). Confirmed live on the owlab stand: `ubus call service list
|
|
// '{"name":"cron"}'` answers `{"cron":{"instances":{"instance1":
|
|
// {"running":true,...}}}}` while crond is up, and a bare `{}` (no "cron"
|
|
// key at all) the moment `/etc/init.d/cron stop` has run -- that absence
|
|
// is exactly the signal this view treats as "not running".
|
|
var callServiceList = rpc.declare({
|
|
object: 'service',
|
|
method: 'list',
|
|
params: [ 'name' ]
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Remote URL parsing / provider deep link (view-commit URL)
|
|
//
|
|
// Deliberately NOT calling remoteurl.sh's gb_deeplink (package/gitbackup,
|
|
// out of this ticket's zone): that function builds the provider's *add a
|
|
// deploy key* settings page for the Settings tab (ticket 12), not a *view
|
|
// this commit* URL, and there is no rpcd method exposing it or an
|
|
// equivalent -- root/usr/share/rpcd/acl.d/luci-app-gitbackup.json's read
|
|
// tier lists exactly status/log/diff/pubkey/list_paths/history/
|
|
// audit_paths/validate_cron, nothing named "deeplink". This mirrors
|
|
// gb_parse_url/gb_provider's own per-host mapping (remoteurl.sh) in
|
|
// client JS instead, scoped to this view's own need: a link straight to
|
|
// the commit, not to the key-management page.
|
|
// ---------------------------------------------------------------------
|
|
|
|
function gbParseRemote(url) {
|
|
var m;
|
|
|
|
url = url || '';
|
|
|
|
m = url.match(/^https:\/\/([^/:]+)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
if (m)
|
|
return { host: m[1], owner: m[2], repo: m[3] };
|
|
|
|
m = url.match(/^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
if (m)
|
|
return { host: m[1], owner: m[2], repo: m[3] };
|
|
|
|
// scp-like alias syntax: "[user@]host:owner/repo(.git)?" -- same shape
|
|
// remoteurl.sh's gb_parse_url accepts for this form, restricted the
|
|
// same way: nothing before the first ":" may contain a "/".
|
|
m = url.match(/^(?:[^@/:]+@)?([^/:]+):([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
if (m)
|
|
return { host: m[1], owner: m[2], repo: m[3] };
|
|
|
|
return null;
|
|
}
|
|
|
|
function gbProvider(host, override) {
|
|
if (override && override !== 'auto')
|
|
return override;
|
|
|
|
switch (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 gbCommitUrl(parsed, provider, sha) {
|
|
if (!parsed || !sha)
|
|
return null;
|
|
|
|
switch (provider) {
|
|
case 'github':
|
|
return 'https://github.com/' + parsed.owner + '/' + parsed.repo + '/commit/' + sha;
|
|
case 'gitlab':
|
|
return 'https://' + parsed.host + '/' + parsed.owner + '/' + parsed.repo + '/-/commit/' + sha;
|
|
case 'gitea':
|
|
return 'https://' + parsed.host + '/' + parsed.owner + '/' + parsed.repo + '/commit/' + sha;
|
|
case 'bitbucket':
|
|
return 'https://bitbucket.org/' + parsed.owner + '/' + parsed.repo + '/commits/' + sha;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Log classification -- "результат последнего прогона" (what the last
|
|
// completed `run` actually did: pushed, found nothing new, was blocked,
|
|
// skipped, failed) is read out of the `log` text (usr/sbin/gitbackup's
|
|
// cmd_log, a tag-filtered `logread`), not out of a dedicated field: what
|
|
// every completed `run` leaves behind is one of a small, fixed set of
|
|
// gb_log lines (usr/sbin/gitbackup, cmd_run/_gb_run_backup), so this
|
|
// reads the most recent one instead of inventing a structured result the
|
|
// backend does not produce.
|
|
//
|
|
// This is deliberately NOT how the live "has the config drifted from the
|
|
// last commit" indicator is computed any more -- that one used to be
|
|
// approximated from this same log line (a completed run's outcome, as of
|
|
// whenever that run finished) for lack of anything better, which could
|
|
// not reflect a single change made on the router afterward. It now comes
|
|
// from a dedicated, always-current check instead: see
|
|
// gbConfigDiffView/refreshConfigDiff below, backed by the config_diff
|
|
// rpcd method.
|
|
// ---------------------------------------------------------------------
|
|
|
|
function gbClassifyLog(text) {
|
|
var lines = (text || '').split('\n');
|
|
var i, line;
|
|
|
|
for (i = lines.length - 1; i >= 0; i--) {
|
|
line = lines[i];
|
|
if (!line)
|
|
continue;
|
|
|
|
if (line.indexOf('publicly visible to an anonymous request') !== -1)
|
|
return {
|
|
kind: 'blocked_public',
|
|
synced: null,
|
|
message: _('Blocked: the repository is publicly visible to an anonymous request; the last push was refused.')
|
|
};
|
|
|
|
if (/pushed [0-9a-f]+ to /.test(line))
|
|
return {
|
|
kind: 'pushed',
|
|
synced: true,
|
|
message: _('Pushed a new commit on the last run.')
|
|
};
|
|
|
|
if (line.indexOf('no changes since the last backup') !== -1)
|
|
return {
|
|
kind: 'no_changes',
|
|
synced: true,
|
|
message: _('No changes since the last commit.')
|
|
};
|
|
|
|
if (line.indexOf('another run already holds the lock') !== -1)
|
|
return {
|
|
kind: 'skipped_lock',
|
|
synced: null,
|
|
message: _('Skipped: another run was already in progress.')
|
|
};
|
|
|
|
if (/unreachable, skipped/.test(line))
|
|
return {
|
|
kind: 'skipped_network',
|
|
synced: null,
|
|
message: _('Skipped: the remote was unreachable.')
|
|
};
|
|
|
|
if (line.indexOf('could not verify whether') !== -1 && line.indexOf('is public') !== -1)
|
|
return {
|
|
kind: 'skipped_visibility',
|
|
synced: null,
|
|
message: _('Skipped: could not verify whether the repository is public.')
|
|
};
|
|
|
|
if (line.indexOf('not enough space') !== -1)
|
|
return {
|
|
kind: 'error_space',
|
|
synced: null,
|
|
message: _('Failed: not enough free space under /tmp to build the backup set.')
|
|
};
|
|
|
|
if (line.indexOf('reachable and authenticated') !== -1)
|
|
return {
|
|
kind: 'test_ok',
|
|
synced: null,
|
|
message: _('Connection test succeeded.')
|
|
};
|
|
}
|
|
|
|
return { kind: 'unknown', synced: null, message: _('No completed run recorded yet.') };
|
|
}
|
|
|
|
// Terminal markers for the live-log poller (handleRun/handleTest, via
|
|
// oplog.js's shared bounded poller): matching any one of these on a
|
|
// freshly-arrived log line means the backgrounded CLI invocation has
|
|
// finished, one way or another. Kept as one regex so oplog's own "did this
|
|
// just finish" check and gbClassifyLog's own "what was the outcome" check
|
|
// can never name a different set of endings by accident.
|
|
var GB_LOG_TERMINAL_RE = new RegExp(
|
|
[
|
|
'pushed [0-9a-f]+ to ',
|
|
'no changes since the last backup',
|
|
'another run already holds the lock',
|
|
'unreachable, skipped',
|
|
'could not verify whether .* is public',
|
|
'not enough space',
|
|
'publicly visible to an anonymous request',
|
|
'reachable and authenticated'
|
|
].join('|')
|
|
);
|
|
|
|
// gbOutcomeKind <cls> -- ticket 23's own "кончилось хорошо/плохо" severity
|
|
// for the transient op-status banner handleRun/handleTest show right after
|
|
// their own poller finishes (see setOpStatus below): 'ok' for a real
|
|
// success, 'error' for the two outcomes gbStatusView's own dot logic also
|
|
// paints red, 'warn' for everything else a completed run/test can report
|
|
// (skipped for lock/network/visibility, or a bare connection-test success
|
|
// line -- gbClassifyLog's 'test_ok', which is a real success but is not
|
|
// "synced" in the run-outcome sense gbStatusView's card cares about at all).
|
|
// Not gbStatusView's own dot-class expression itself: that one also has a
|
|
// fourth, neutral "no color at all" state for a device that has never
|
|
// completed a run ('unknown'), which cannot happen here -- a terminal log
|
|
// line just matched one of GB_LOG_TERMINAL_RE's alternatives, so cls.kind
|
|
// is never 'unknown' at this call site.
|
|
function gbOutcomeKind(cls) {
|
|
if (cls.synced === true || cls.kind === 'test_ok')
|
|
return 'ok';
|
|
if (cls.kind === 'blocked_public' || cls.kind === 'error_space')
|
|
return 'error';
|
|
return 'warn';
|
|
}
|
|
|
|
function gbMissingText(key) {
|
|
switch (key) {
|
|
case 'device_id':
|
|
return _('Device identifier could not be resolved (hostname is still the default "OpenWrt", or a custom device name is empty). Set it on the Settings tab.');
|
|
case 'cron_expr':
|
|
return _('Schedule is set to a custom cron expression, but it is not a valid 5-field busybox crontab line. Fix it on the Settings tab.');
|
|
case 'url':
|
|
return _('Repository URL is not set. Set it on the Settings tab.');
|
|
default:
|
|
return key;
|
|
}
|
|
}
|
|
|
|
function gbScheduleText(schedule, cronExpr, cronNext) {
|
|
var base;
|
|
|
|
switch (schedule) {
|
|
case 'off':
|
|
return _('Disabled');
|
|
case 'hourly':
|
|
base = _('Hourly (minute chosen per device to avoid a fleet-wide stampede)');
|
|
break;
|
|
case 'daily':
|
|
base = _('Daily, once in a randomized night window (00:00-05:59), per device');
|
|
break;
|
|
case 'weekly':
|
|
base = _('Weekly, once in a randomized night window, per device');
|
|
break;
|
|
case 'cron':
|
|
base = _('Custom cron: %s').format(cronExpr || '-');
|
|
if (cronNext)
|
|
base += ' — ' + _('next: %s UTC').format(cronNext);
|
|
return base;
|
|
default:
|
|
return schedule || '-';
|
|
}
|
|
|
|
return base;
|
|
}
|
|
|
|
function gbFormatDate(iso) {
|
|
var d;
|
|
|
|
if (!iso)
|
|
return _('never');
|
|
|
|
d = new Date(iso);
|
|
if (isNaN(d.getTime()))
|
|
return iso;
|
|
|
|
return d.toLocaleString();
|
|
}
|
|
|
|
function gbCommitTime(subject) {
|
|
var m = (subject || '').match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2})/);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Pure view-model builders -- no DOM access at all, deliberately. LuCI's
|
|
// own view lifecycle (luci.js: `ready.then(load).then(render).then(nodes
|
|
// => DOM.content(vp, nodes))`) does not attach this view's tree to
|
|
// `document` until *after* render() has already returned -- confirmed by
|
|
// reading that chain directly. A `document.getElementById(...)` call
|
|
// made synchronously inside render() therefore always misses (the node
|
|
// exists only in memory at that point) and silently does nothing, since
|
|
// every DOM update below is behind an `if (el)` guard. These two
|
|
// functions compute what the initial paint AND every later poll tick
|
|
// should show, as plain data; render() below feeds their result straight
|
|
// into the E() tree it builds, and applyStatusDom/applyHistoryDom (used
|
|
// only from refreshStatus/refreshHistory, i.e. only after the view is
|
|
// already live) push the same shape into the DOM by id.
|
|
// ---------------------------------------------------------------------
|
|
|
|
function gbStatusView(status, logRes, serviceRes, schedule, cronExpr, cronNext) {
|
|
var cls = gbClassifyLog((logRes && logRes.text) || '');
|
|
var banners = [];
|
|
var cronRunning = false;
|
|
var k;
|
|
|
|
if (!status.configured && status.missing && status.missing.length) {
|
|
banners.push(_('Configuration is incomplete:') + ' ' + status.missing.map(gbMissingText).join(' '));
|
|
}
|
|
|
|
if (serviceRes && serviceRes.cron && serviceRes.cron.instances) {
|
|
for (k in serviceRes.cron.instances) {
|
|
if (serviceRes.cron.instances[k] && serviceRes.cron.instances[k].running)
|
|
cronRunning = true;
|
|
}
|
|
}
|
|
if (!cronRunning && schedule !== 'off') {
|
|
banners.push(_('The cron service (crond) does not appear to be running -- scheduled backups will not happen until it does. Run "/etc/init.d/cron start" or check the System > Startup page.'));
|
|
}
|
|
|
|
if (cls.kind === 'blocked_public')
|
|
banners.push(cls.message);
|
|
|
|
return {
|
|
enabledText: status.enabled ? _('Enabled') : _('Disabled'),
|
|
scheduleText: gbScheduleText(schedule, cronExpr, cronNext),
|
|
resultText: cls.message,
|
|
resultDotClass: 'gitbackup-dot ' + (
|
|
cls.synced === true ? 'gitbackup-dot-ok' :
|
|
cls.kind === 'blocked_public' || cls.kind === 'error_space' ? 'gitbackup-dot-error' :
|
|
cls.synced === null && cls.kind !== 'unknown' ? 'gitbackup-dot-warn' : ''
|
|
),
|
|
resultHint: status.last_run ?
|
|
_('Last completed run: %s').format(gbFormatDate(status.last_run)) :
|
|
_('This device has not completed a run yet.'),
|
|
banners: banners
|
|
};
|
|
}
|
|
|
|
function gbHistoryView(historyRes, remoteUrl, provider) {
|
|
var commit = (historyRes && historyRes.commits && historyRes.commits[0]) || null;
|
|
var parsed, href;
|
|
|
|
if (!commit)
|
|
return { shaText: _('No commits yet'), shaHref: null, timeText: '-' };
|
|
|
|
parsed = gbParseRemote(remoteUrl);
|
|
href = gbCommitUrl(parsed, gbProvider(parsed ? parsed.host : '', provider), commit.sha);
|
|
|
|
return {
|
|
shaText: commit.sha.substring(0, 12),
|
|
shaHref: href,
|
|
timeText: gbCommitTime(commit.subject) || commit.subject || '-'
|
|
};
|
|
}
|
|
|
|
// gbConfigDiffView <res> -- pure view-model for the config_diff rpcd
|
|
// answer (`{ differs: bool, text: ... }` on success, `{ reason: ... }` on
|
|
// failure -- gbrpc_config_diff, package/gitbackup). Unlike gbClassifyLog
|
|
// above, a missing/failed answer must NOT read as "all saved": that would
|
|
// misreport "could not check" as the one state operators most need to
|
|
// trust (spec: "если метод недоступен или упал, индикатор обязан честно
|
|
// сказать «не удалось проверить»"), so both "the call itself rejected"
|
|
// (res === null, this view's own L.resolveDefault fallback) and "the
|
|
// method ran but answered an error object" (res.reason) share the same
|
|
// "could not check" branch below, rendered with the neutral/warn dot,
|
|
// never the ok one.
|
|
function gbConfigDiffView(res) {
|
|
if (!res || typeof res.differs !== 'boolean')
|
|
return {
|
|
text: (res && res.reason) ?
|
|
_('Could not check: %s').format(res.reason) :
|
|
_('Could not check whether the configuration has changed.'),
|
|
dotClass: 'gitbackup-dot gitbackup-dot-warn'
|
|
};
|
|
|
|
if (res.differs)
|
|
return {
|
|
text: _('The configuration has changed since the last commit.'),
|
|
dotClass: 'gitbackup-dot gitbackup-dot-warn'
|
|
};
|
|
|
|
return {
|
|
text: _('The configuration matches the last commit.'),
|
|
dotClass: 'gitbackup-dot gitbackup-dot-ok'
|
|
};
|
|
}
|
|
|
|
function gbRenderBanner(text) {
|
|
return E('div', { 'class': 'gitbackup-banner' }, [ E('p', {}, [ text ]) ]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Styles -- returned inside this view's own tree (rule 1), never through
|
|
// document.head.appendChild, the same pattern stock package-manager.js
|
|
// and status/nftables.js use. Every selector is prefixed "gitbackup-"
|
|
// (rule 2): no bare .hidden/.toast/.label/.card anywhere. No `:root{}`,
|
|
// no `*{}`, no CSS-framework import (rule 3). Every color comes from the
|
|
// theme's exported token tier with a literal fallback (rule 4); the
|
|
// "warn" family is spelled "warn", not "warning" (there is no bare
|
|
// `--text-color` either). Text on a colored fill uses the matching
|
|
// `--on-*-color`, never a literal white (rule 5). No `--fs-*` read
|
|
// anywhere (rule 6, private theme tier). No `!important` (rule 7). No
|
|
// dark-mode media query -- every color already comes from a token, so no
|
|
// `data-darkmode` detection is needed at all (rule 8). Layout switches on
|
|
// this view's own container width via `@container`, not `@media` (rule
|
|
// 9): the sidebar eats real width a viewport-wide media query cannot see.
|
|
// No `window.onload`, no node appended to document.body, no absurd
|
|
// z-index (rule 10) -- and the one poller this view runs against `log`
|
|
// (oplog.js's shared instance, started from handleRun/handleTest) is
|
|
// bounded and explicitly stopped by that shared module itself. Stock
|
|
// Save/Apply/Reset buttons are switched off
|
|
// through handleSaveApply/handleSave/handleReset below, never hidden with
|
|
// CSS (rule 11).
|
|
//
|
|
// Defined here, before the view.extend() call below that references it
|
|
// inside render(): this file's own top-level code all runs once, in
|
|
// order, before any view lifecycle method is ever invoked, but a
|
|
// `return` at the top level of a LuCI view module (the `return
|
|
// view.extend({...})` below) hands control back to the module loader
|
|
// immediately -- every stock view in this checkout ends with exactly
|
|
// that as its last statement, none of them have code after it, and a
|
|
// `var` placed after such a `return` would be dead code, never
|
|
// evaluated at all.
|
|
// ---------------------------------------------------------------------
|
|
var GB_CSS = [
|
|
'.gitbackup-view { container-type: inline-size; }',
|
|
'.gitbackup-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: .75em; margin: .75em 0; }',
|
|
'@container (max-width: 700px) { .gitbackup-cards { grid-template-columns: 1fr; } }',
|
|
'.gitbackup-card, .gitbackup-card-clickable { border: 1px solid var(--background-color-medium, #ddd); border-radius: 4px; padding: .75em 1em; background: var(--background-color-low, #f5f5f5); }',
|
|
// The "Configuration" card is a real <button>, not a <div> (ticket 23:
|
|
// "по нажатию открывается расхождение") -- this undoes the browser's
|
|
// own button chrome (font/color/alignment/full-width) while keeping
|
|
// the box styling every other card already gets from the shared rule
|
|
// just above, and adds back the pointer/focus affordances a clickable
|
|
// card needs that a plain informational one does not.
|
|
'.gitbackup-card-clickable { display: block; width: 100%; text-align: left; font: inherit; color: inherit; cursor: pointer; }',
|
|
'.gitbackup-card-clickable:hover, .gitbackup-card-clickable:focus-visible { border-color: var(--text-color-medium, #666); }',
|
|
'.gitbackup-card-title { font-size: .85em; text-transform: uppercase; letter-spacing: .04em; color: var(--text-color-medium, #666); margin: 0 0 .35em; }',
|
|
'.gitbackup-card-value { font-size: 1.05em; color: var(--text-color-high, #333); word-break: break-word; }',
|
|
'.gitbackup-card-hint { font-size: .85em; color: var(--text-color-medium, #666); margin-top: .35em; }',
|
|
// gitbackup-hint-ok/-warn/-error -- the same three severities
|
|
// settings.js's own copy of these two already uses (ticket 12),
|
|
// reused here (plus a "warn" middle severity settings.js's own
|
|
// connection test never needed) for the op-status line handleRun/
|
|
// handleTest show under the action buttons (ticket 23, spec "Три
|
|
// состояния... кончилось хорошо... кончилось плохо").
|
|
'.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-op-status { font-weight: bold; }',
|
|
'.gitbackup-link { color: var(--text-color-high, #333); }',
|
|
'.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-leak { border: 1px solid var(--warn-color-medium, #f0c629); border-radius: 4px; padding: .75em 1em; margin: .75em 0; background: var(--background-color-low, #f5f5f5); }',
|
|
'.gitbackup-leak-title { font-weight: bold; color: var(--text-color-high, #333); margin: 0 0 .4em; }',
|
|
'.gitbackup-leak-list { margin: 0; padding-left: 1.2em; color: var(--text-color-high, #333); }',
|
|
'.gitbackup-actions { display: flex; flex-wrap: wrap; gap: .5em; margin: .75em 0; }',
|
|
'.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-dot { display: inline-block; width: .6em; height: .6em; border-radius: 50%; margin-right: .4em; background: var(--text-color-medium, #999); }',
|
|
'.gitbackup-dot-ok { background: var(--success-color-medium, #4caf50); }',
|
|
'.gitbackup-dot-warn { background: var(--warn-color-medium, #f0c629); }',
|
|
'.gitbackup-dot-error { background: var(--error-color-medium, #f44336); }',
|
|
'.gitbackup-modal-actions { display: flex; justify-content: flex-end; gap: .5em; margin-top: 1em; }'
|
|
];
|
|
|
|
return view.extend({
|
|
// Not a config form: nothing here is form.Map-backed, so the stock
|
|
// Save/Apply/Reset row has nothing to act on. Rule 11 (spec's style
|
|
// guide): the stock buttons are switched off through the documented
|
|
// hook, never hidden with CSS.
|
|
handleSaveApply: null,
|
|
handleSave: null,
|
|
handleReset: null,
|
|
|
|
load: function() {
|
|
var self = this;
|
|
|
|
return uci.load('gitbackup').catch(function() { return null; }).then(function() {
|
|
var schedule = uci.get('gitbackup', 'main', 'schedule') || 'daily';
|
|
var cronExpr = uci.get('gitbackup', 'main', 'cron_expr') || '';
|
|
|
|
self._remoteUrl = uci.get('gitbackup', 'origin', 'url') || '';
|
|
self._provider = uci.get('gitbackup', 'origin', 'provider') || 'auto';
|
|
self._schedule = schedule;
|
|
self._cronExpr = cronExpr;
|
|
|
|
// `history` is deliberately NOT in this Promise.all: it runs a
|
|
// real `git fetch` against the configured remote
|
|
// (usr/libexec/rpcd/luci.gitbackup's own _gb_rpc_full_fetch,
|
|
// out of this ticket's zone) with no timeout of its own, and a
|
|
// router with a genuinely unreachable remote (no internet yet,
|
|
// wrong URL, DNS down) can leave that hanging far longer than
|
|
// the couple of seconds a normal fetch takes -- measured live
|
|
// against a real GitHub remote on the owlab stand. Blocking
|
|
// this view's entire initial paint on that would turn "no
|
|
// internet" into "Overview never loads at all", which is
|
|
// worse than the Last Commit card staying on "Checking..." a
|
|
// while. render() below starts it separately, after the rest
|
|
// of the page (status/log/service, all local-only and fast)
|
|
// is already on screen.
|
|
return Promise.all([
|
|
L.resolveDefault(callStatus(), null),
|
|
L.resolveDefault(callLog(200), null),
|
|
L.resolveDefault(callServiceList('cron'), null),
|
|
(schedule === 'cron' && cronExpr) ? L.resolveDefault(callValidateCron(cronExpr), null) : null
|
|
]);
|
|
});
|
|
},
|
|
|
|
render: function(data) {
|
|
var self = this;
|
|
var status = data[0] || {};
|
|
var logRes = data[1] || {};
|
|
var serviceRes = data[2] || {};
|
|
var cronRes = data[3] || null;
|
|
var sv, view;
|
|
|
|
self._cronNext = (cronRes && cronRes.valid) ? cronRes.next : null;
|
|
|
|
// Computed up front, as plain data, and fed directly into the
|
|
// E() tree below -- see the "Pure view-model builders" comment
|
|
// above gbStatusView/gbHistoryView for why this view never calls
|
|
// applyStatusDom/applyHistoryDom (the getElementById-based
|
|
// updaters used by the later poll ticks) against its own,
|
|
// not-yet-attached initial tree. The commit card starts on a
|
|
// plain "Checking..." placeholder -- not gbHistoryView({}, ...)'s
|
|
// own "No commits yet", which would misreport a remote that
|
|
// simply hasn't answered back yet as one confirmed to have no
|
|
// history -- and refreshHistory() (below, fired once render()
|
|
// returns) fills in the real answer whenever it arrives.
|
|
sv = gbStatusView(status, logRes, serviceRes, self._schedule, self._cronExpr, self._cronNext);
|
|
|
|
view = E('div', { 'class': 'gitbackup-view' }, [
|
|
E('style', { 'type': 'text/css' }, [ GB_CSS.concat(diffview.css).join('\n') ]),
|
|
|
|
E('h2', {}, [ _('Git Backup') ]),
|
|
|
|
E('div', { 'id': 'gitbackup-banners' }, sv.banners.map(gbRenderBanner)),
|
|
|
|
E('div', { 'class': 'gitbackup-cards' }, [
|
|
E('div', { 'class': 'gitbackup-card' }, [
|
|
E('div', { 'class': 'gitbackup-card-title' }, [ _('Status') ]),
|
|
E('div', { 'class': 'gitbackup-card-value', 'id': 'gitbackup-status-enabled' }, [ sv.enabledText ]),
|
|
E('div', { 'class': 'gitbackup-card-hint', 'id': 'gitbackup-status-schedule' }, [ sv.scheduleText ])
|
|
]),
|
|
E('div', { 'class': 'gitbackup-card' }, [
|
|
E('div', { 'class': 'gitbackup-card-title' }, [ _('Last commit') ]),
|
|
E('div', { 'class': 'gitbackup-card-value', 'id': 'gitbackup-commit-sha' }, [ _('Checking…') ]),
|
|
E('div', { 'class': 'gitbackup-card-hint', 'id': 'gitbackup-commit-time' }, [ '-' ])
|
|
]),
|
|
E('div', { 'class': 'gitbackup-card' }, [
|
|
E('div', { 'class': 'gitbackup-card-title' }, [ _('Last run') ]),
|
|
E('div', { 'class': 'gitbackup-card-value', 'id': 'gitbackup-run-result' }, [
|
|
E('span', { 'class': sv.resultDotClass, 'id': 'gitbackup-run-dot' }),
|
|
E('span', { 'id': 'gitbackup-run-text' }, [ sv.resultText ])
|
|
]),
|
|
E('div', { 'class': 'gitbackup-card-hint', 'id': 'gitbackup-run-hint' }, [ sv.resultHint ])
|
|
]),
|
|
// A real <button>, not a <div> like the three cards above
|
|
// (ticket 23: "Блок «Configuration»... по ней можно
|
|
// посмотреть diff") -- gbConfigDiffView's own text already
|
|
// answers "did it drift"; clicking the whole card answers
|
|
// the natural follow-up, "what exactly changed", without a
|
|
// second, separate control competing for the same space.
|
|
E('button', {
|
|
'class': 'gitbackup-card gitbackup-card-clickable',
|
|
'id': 'gitbackup-configdiff-card',
|
|
'click': ui.createHandlerFn(self, 'handleShowConfigDiff')
|
|
}, [
|
|
E('div', { 'class': 'gitbackup-card-title' }, [ _('Configuration') ]),
|
|
E('div', { 'class': 'gitbackup-card-value', 'id': 'gitbackup-configdiff-result' }, [
|
|
E('span', { 'class': 'gitbackup-dot', 'id': 'gitbackup-configdiff-dot' }),
|
|
E('span', { 'id': 'gitbackup-configdiff-text' }, [ _('Checking…') ])
|
|
]),
|
|
E('div', { 'class': 'gitbackup-card-hint' },
|
|
[ _('Compared against the last commit right now, not just the last run. Click to view the diff.') ])
|
|
])
|
|
]),
|
|
|
|
E('div', { 'class': 'gitbackup-leak' }, [
|
|
E('p', { 'class': 'gitbackup-leak-title' },
|
|
[ _('The following ends up in the repository in plain text -- there is no encryption in this tool:') ]),
|
|
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' ])
|
|
])
|
|
]),
|
|
|
|
E('div', { 'class': 'gitbackup-actions' }, [
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-action',
|
|
'id': 'gitbackup-btn-run',
|
|
'click': ui.createHandlerFn(self, 'handleRun')
|
|
}, [ _('Backup now') ]),
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-neutral',
|
|
'id': 'gitbackup-btn-test',
|
|
'click': ui.createHandlerFn(self, 'handleTest')
|
|
}, [ _('Test connection') ]),
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-neutral',
|
|
'id': 'gitbackup-btn-card',
|
|
'click': ui.createHandlerFn(self, 'handleDownloadCard')
|
|
}, [ _('Download recovery card') ])
|
|
]),
|
|
|
|
// The one status line shared by "Backup now" and "Test
|
|
// connection" (ticket 23: "по клику... сразу появляется
|
|
// видимый индикатор хода... каждая долгая операция
|
|
// заканчивается явным сообщением"). "spinning" is stock LuCI
|
|
// CSS (confirmed in every theme's own cascade.css, stock
|
|
// bootstrap included -- not this project's own invention): an
|
|
// actually-animated icon, which is what tells an operator
|
|
// "still working" apart from "stuck" the way static text
|
|
// alone cannot.
|
|
E('p', { 'class': 'gitbackup-op-status', 'id': 'gitbackup-op-status', 'hidden': true }),
|
|
|
|
E('pre', { 'class': 'gitbackup-log', 'id': 'gitbackup-live-log', 'hidden': true }, [ '' ])
|
|
]);
|
|
|
|
self._boundRefresh = L.bind(self.refreshStatus, self);
|
|
poll.add(self._boundRefresh, 5);
|
|
|
|
// Fired once, after render() has already built and returned the
|
|
// tree above -- by the time this promise resolves the view is
|
|
// attached (LuCI's own view lifecycle inserts render()'s return
|
|
// value right after render() itself returns), so
|
|
// applyHistoryDom's getElementById calls land on live nodes. See
|
|
// the "not in load()'s Promise.all" comment on load() for why
|
|
// this is a fire-and-forget call rather than something render()
|
|
// waits on.
|
|
self.refreshHistory();
|
|
|
|
// Same reasoning as refreshHistory() just above, and NOT folded
|
|
// into refreshStatus()'s own 5s poll either: config_diff
|
|
// (gbrpc_config_diff) re-collects the whole backup set and talks
|
|
// to the remote to fetch the last commit's manifest, the same
|
|
// "can take several seconds against a real remote" cost this
|
|
// view's own commit-history fetch already has to dodge -- polling
|
|
// that every 5 seconds would hammer both the router's flash and
|
|
// the remote for no reason. Refreshed here once the page is
|
|
// already on screen, and again after a run/test this view itself
|
|
// triggered finishes (handleRun/handleTest's own oplog.start() onFinish
|
|
// callback).
|
|
self.refreshConfigDiff();
|
|
|
|
return view;
|
|
},
|
|
|
|
// Cheap periodic refresh: status (local file reads) and a short log
|
|
// tail (local logread), never `history` -- that method does a real
|
|
// `git fetch` against the remote on every call (usr/libexec/rpcd/
|
|
// luci.gitbackup's own _gb_rpc_full_fetch), and polling it every 5
|
|
// seconds would hammer the remote for no reason. `history` is
|
|
// refreshed once on load and again right after a run/test this view
|
|
// itself triggered finishes (see handleRun/handleTest). Both of these run only
|
|
// after the view is already attached (poll ticks always fire well
|
|
// after render() has returned), so applyStatusDom/applyHistoryDom's
|
|
// own getElementById calls are safe here -- unlike inside render()
|
|
// itself, see the "Pure view-model builders" comment above.
|
|
refreshStatus: function() {
|
|
var self = this;
|
|
|
|
return Promise.all([
|
|
L.resolveDefault(callStatus(), null),
|
|
L.resolveDefault(callLog(200), null),
|
|
L.resolveDefault(callServiceList('cron'), null)
|
|
]).then(function(data) {
|
|
self.applyStatusDom(gbStatusView(
|
|
data[0] || {}, data[1] || {}, data[2] || {},
|
|
self._schedule, self._cronExpr, self._cronNext
|
|
));
|
|
});
|
|
},
|
|
|
|
refreshHistory: function() {
|
|
var self = this;
|
|
|
|
return L.resolveDefault(callHistory(1), null).then(function(res) {
|
|
self.applyHistoryDom(gbHistoryView(res || {}, self._remoteUrl, self._provider));
|
|
});
|
|
},
|
|
|
|
// L.resolveDefault's own `null` fallback (a rejected call: rpcd
|
|
// unreachable, ACL denial, ...) and gbrpc_config_diff's own
|
|
// `{ "reason": ... }` error object (a reachable failure: bad config,
|
|
// unreachable remote) both have to land on gbConfigDiffView's "could
|
|
// not check" branch, never silently on "all saved" -- see that
|
|
// function's own comment.
|
|
refreshConfigDiff: function() {
|
|
var self = this;
|
|
|
|
return L.resolveDefault(callConfigDiff(), null).then(function(res) {
|
|
// Cached for handleShowConfigDiff below, so a click can open
|
|
// the modal instantly with whatever this view already knows
|
|
// instead of waiting on a fresh round trip through uhttpd's own
|
|
// "-n 3" concurrent-call ceiling (interfaces.md, ticket 13) --
|
|
// the modal itself always re-fetches anyway (see that handler's
|
|
// own comment), this only avoids a blank "Loading…" flash for
|
|
// the common case where nothing has changed since this ran.
|
|
self._configDiffRes = res;
|
|
self.applyConfigDiffDom(gbConfigDiffView(res));
|
|
});
|
|
},
|
|
|
|
applyStatusDom: function(sv) {
|
|
var enabledEl = document.getElementById('gitbackup-status-enabled');
|
|
var scheduleEl = document.getElementById('gitbackup-status-schedule');
|
|
var resultTextEl = document.getElementById('gitbackup-run-text');
|
|
var resultDotEl = document.getElementById('gitbackup-run-dot');
|
|
var resultHintEl = document.getElementById('gitbackup-run-hint');
|
|
var bannerBox = document.getElementById('gitbackup-banners');
|
|
|
|
if (enabledEl)
|
|
enabledEl.textContent = sv.enabledText;
|
|
|
|
if (scheduleEl)
|
|
scheduleEl.textContent = sv.scheduleText;
|
|
|
|
if (resultTextEl)
|
|
resultTextEl.textContent = sv.resultText;
|
|
|
|
if (resultDotEl)
|
|
resultDotEl.className = sv.resultDotClass;
|
|
|
|
if (resultHintEl)
|
|
resultHintEl.textContent = sv.resultHint;
|
|
|
|
if (bannerBox) {
|
|
bannerBox.textContent = '';
|
|
sv.banners.forEach(function(b) { bannerBox.appendChild(gbRenderBanner(b)); });
|
|
}
|
|
},
|
|
|
|
applyHistoryDom: function(hv) {
|
|
var shaEl = document.getElementById('gitbackup-commit-sha');
|
|
var timeEl = document.getElementById('gitbackup-commit-time');
|
|
|
|
if (!shaEl || !timeEl)
|
|
return;
|
|
|
|
shaEl.textContent = '';
|
|
if (hv.shaHref) {
|
|
shaEl.appendChild(E('a', {
|
|
'href': hv.shaHref,
|
|
'target': '_blank',
|
|
'rel': 'noopener noreferrer',
|
|
'class': 'gitbackup-link'
|
|
}, [ hv.shaText ]));
|
|
} else {
|
|
shaEl.appendChild(document.createTextNode(hv.shaText));
|
|
}
|
|
|
|
timeEl.textContent = hv.timeText;
|
|
},
|
|
|
|
applyConfigDiffDom: function(cv) {
|
|
var textEl = document.getElementById('gitbackup-configdiff-text');
|
|
var dotEl = document.getElementById('gitbackup-configdiff-dot');
|
|
|
|
if (textEl)
|
|
textEl.textContent = cv.text;
|
|
|
|
if (dotEl)
|
|
dotEl.className = cv.dotClass;
|
|
},
|
|
|
|
setBusy: function(busy) {
|
|
[ 'gitbackup-btn-run', 'gitbackup-btn-test', 'gitbackup-btn-card' ].forEach(function(id) {
|
|
var btn = document.getElementById(id);
|
|
if (btn)
|
|
btn.disabled = busy;
|
|
});
|
|
},
|
|
|
|
// setOpStatus <kind> <text> -- the one line shared by "Backup now" and
|
|
// "Test connection" for all three states ticket 23 asks for: 'busy'
|
|
// (spinning, shown the instant the button is clicked, before the rpcd
|
|
// call has even answered), 'ok'/'warn'/'error' (shown once the poller
|
|
// below actually knows how the operation went). 'warn' covers every
|
|
// completed-but-not-a-clean-success outcome gbOutcomeKind can report
|
|
// (skipped for lock/network/visibility) as well as "no result after a
|
|
// while" -- none of those are the emphatic red "error" gbStatusView's
|
|
// own dot reserves for a refused/failed push.
|
|
setOpStatus: function(kind, text) {
|
|
var el = document.getElementById('gitbackup-op-status');
|
|
|
|
if (!el)
|
|
return;
|
|
|
|
el.hidden = false;
|
|
el.className = 'gitbackup-op-status' + (kind === 'busy' ? ' spinning' : ' gitbackup-hint-' + kind);
|
|
el.textContent = text;
|
|
},
|
|
|
|
// showLiveLog -- unhides and clears the raw log tail under the status
|
|
// line above; kept separate from setOpStatus so a caller can show the
|
|
// "busy" status and reset the log pane in one obvious pair of calls
|
|
// (see handleRun/handleTest below).
|
|
showLiveLog: function() {
|
|
var pre = document.getElementById('gitbackup-live-log');
|
|
|
|
if (pre) {
|
|
pre.hidden = false;
|
|
pre.textContent = '';
|
|
}
|
|
},
|
|
|
|
appendLiveLog: function(add) {
|
|
var pre = document.getElementById('gitbackup-live-log');
|
|
|
|
if (!pre)
|
|
return;
|
|
|
|
pre.textContent = pre.textContent ? pre.textContent + '\n' + add : add;
|
|
pre.scrollTop = pre.scrollHeight;
|
|
},
|
|
|
|
// handleRun/handleTest -- both back "Backup now"/"Test connection" with
|
|
// oplog.js's shared bounded live-log poller (ticket 23: "не изобретать
|
|
// четвёртый механизм"; oplog.js's own header comment explains why one
|
|
// shared, auto-instantiated instance is safe for every view here to
|
|
// reuse). Both rpcd methods (run/test) background the CLI and answer
|
|
// immediately with `{started:true}` (usr/libexec/rpcd/luci.gitbackup's
|
|
// own gbrpc_run/gbrpc_test) -- the only way to see how it went is to
|
|
// tail the same syslog-backed `log` method, so the poller is started
|
|
// FIRST (establishing the current line count) and the rpcd call second,
|
|
// so a very fast operation's own first output line is never missed.
|
|
handleRun: function(ev) {
|
|
var self = this;
|
|
|
|
self.setBusy(true);
|
|
self.setOpStatus('busy', _('Running a backup…'));
|
|
self.showLiveLog();
|
|
|
|
return oplog.start({
|
|
fetch: function() { return L.resolveDefault(callLog(500), null).then(function(res) { return (res && res.text) || ''; }); },
|
|
terminalRe: GB_LOG_TERMINAL_RE,
|
|
onProgress: function(add) { self.appendLiveLog(add); },
|
|
onFinish: function(line) {
|
|
var cls = gbClassifyLog(line);
|
|
self.setBusy(false);
|
|
self.setOpStatus(gbOutcomeKind(cls), cls.message);
|
|
self.refreshStatus();
|
|
self.refreshHistory();
|
|
self.refreshConfigDiff();
|
|
},
|
|
onTimeout: function() {
|
|
self.setBusy(false);
|
|
self.setOpStatus('warn', _('No result after a while -- check the log below or the syslog by hand.'));
|
|
self.refreshStatus();
|
|
}
|
|
}).then(function() {
|
|
return callRun();
|
|
}).then(function(res) {
|
|
if (!res || res.started !== true) {
|
|
oplog.stop();
|
|
self.setBusy(false);
|
|
self.setOpStatus('error', _('Could not start a backup run.'));
|
|
}
|
|
}).catch(function(e) {
|
|
oplog.stop();
|
|
self.setBusy(false);
|
|
self.setOpStatus('error', _('Could not start a backup run: %s').format(e.message));
|
|
});
|
|
},
|
|
|
|
handleTest: function(ev) {
|
|
var self = this;
|
|
|
|
self.setBusy(true);
|
|
self.setOpStatus('busy', _('Testing the connection…'));
|
|
self.showLiveLog();
|
|
|
|
return oplog.start({
|
|
fetch: function() { return L.resolveDefault(callLog(500), null).then(function(res) { return (res && res.text) || ''; }); },
|
|
terminalRe: GB_LOG_TERMINAL_RE,
|
|
onProgress: function(add) { self.appendLiveLog(add); },
|
|
onFinish: function(line) {
|
|
var cls = gbClassifyLog(line);
|
|
self.setBusy(false);
|
|
self.setOpStatus(gbOutcomeKind(cls), cls.message);
|
|
self.refreshStatus();
|
|
},
|
|
onTimeout: function() {
|
|
self.setBusy(false);
|
|
self.setOpStatus('warn', _('No result after a while -- check the log below or the syslog by hand.'));
|
|
}
|
|
}).then(function() {
|
|
return callTest();
|
|
}).then(function(res) {
|
|
if (!res || res.started !== true) {
|
|
oplog.stop();
|
|
self.setBusy(false);
|
|
self.setOpStatus('error', _('Could not start a connection test.'));
|
|
}
|
|
}).catch(function(e) {
|
|
oplog.stop();
|
|
self.setBusy(false);
|
|
self.setOpStatus('error', _('Could not start a connection test: %s').format(e.message));
|
|
});
|
|
},
|
|
|
|
// handleShowConfigDiff -- ticket 23: the "Configuration" card is now a
|
|
// button (render() above); this is its click handler. Always re-fetches
|
|
// (never trusts refreshConfigDiff's own cached self._configDiffRes
|
|
// alone, though that IS used as the modal's very first paint, to avoid
|
|
// a blank flash) -- config_diff re-collects the whole backup set on
|
|
// every call (gbrpc_config_diff, usr/libexec/rpcd/luci.gitbackup), so
|
|
// the config could easily have changed again since the last periodic
|
|
// refresh, and this is the one place an operator explicitly asked to
|
|
// see the current truth, not a five-seconds-old approximation of it.
|
|
handleShowConfigDiff: function(ev) {
|
|
var self = this;
|
|
|
|
ui.showModal(_('Configuration diff'), [
|
|
E('p', { 'class': 'spinning' }, [ _('Checking the current configuration against the last commit…') ])
|
|
]);
|
|
|
|
if (self._configDiffRes)
|
|
self.renderConfigDiffModal(self._configDiffRes);
|
|
|
|
return L.resolveDefault(callConfigDiff(), null).then(function(res) {
|
|
self._configDiffRes = res;
|
|
self.applyConfigDiffDom(gbConfigDiffView(res));
|
|
self.renderConfigDiffModal(res);
|
|
});
|
|
},
|
|
|
|
// renderConfigDiffModal <res> -- <res> is gbrpc_config_diff's own raw
|
|
// answer (`{differs, text}` or `{reason}`), not gbConfigDiffView's
|
|
// view-model: the manifest-format text itself (diffview.manifest) is
|
|
// exactly the one field that model deliberately throws away (see that
|
|
// function's own comment), and this is the one place it is shown.
|
|
renderConfigDiffModal: function(res) {
|
|
var body;
|
|
|
|
if (!res || typeof res.differs !== 'boolean') {
|
|
body = [ E('p', { 'class': 'gitbackup-hint-error' },
|
|
[ (res && res.reason) ?
|
|
_('Could not check: %s').format(res.reason) :
|
|
_('Could not check whether the configuration has changed.') ]) ];
|
|
} else if (!res.differs) {
|
|
body = [ E('p', { 'class': 'gitbackup-hint-ok' }, [ _('The configuration matches the last commit.') ]) ];
|
|
} else {
|
|
body = [ diffview.manifest(res.text) ];
|
|
}
|
|
|
|
body.push(E('div', { 'class': 'gitbackup-modal-actions' }, [
|
|
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, [ _('Close') ])
|
|
]));
|
|
|
|
ui.showModal(_('Configuration diff'), body);
|
|
},
|
|
|
|
handleDownloadCard: function(ev) {
|
|
var btn = ev.target;
|
|
var link;
|
|
|
|
btn.disabled = true;
|
|
|
|
return callCard().then(function(res) {
|
|
if (!res || typeof res.card !== 'string') {
|
|
ui.addNotification(null, E('p', {},
|
|
[ _('Could not generate the recovery card: %s').format((res && res.reason) || _('unknown error')) ]), 'error');
|
|
return;
|
|
}
|
|
|
|
link = document.createElement('a');
|
|
link.href = window.URL.createObjectURL(new Blob([ res.card ], { type: 'text/markdown' }));
|
|
link.download = 'gitbackup-RECOVERY.md';
|
|
link.click();
|
|
window.URL.revokeObjectURL(link.href);
|
|
}).catch(function(e) {
|
|
ui.addNotification(null, E('p', {}, [ _('Could not generate the recovery card: %s').format(e.message) ]), 'error');
|
|
}).finally(function() {
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
});
|