'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 -- 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 -- 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