'use strict'; 'require view'; 'require poll'; 'require rpc'; 'require uci'; 'require ui'; 'require view.gitbackup.common as common'; 'require view.gitbackup.oplog as oplog'; /* global common, oplog */ // openwrt/luci's eslint config only knows stock module names as globals. var callConfigDiff = rpc.declare({ object: 'luci.gitbackup', method: 'config_diff' }); var callRun = rpc.declare({ object: 'luci.gitbackup', method: 'run' }); var callCard = rpc.declare({ object: 'luci.gitbackup', method: 'card' }); // procd's stock object: {"cron":{"instances":{...running:true}}} while crond // is up, a bare {} once it is stopped. var callServiceList = rpc.declare({ object: 'service', method: 'list', params: [ 'name' ] }); 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.') }; } // Checked against tests/fixtures/terminal-markers.tsv. 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('|') ); function gbOutcomeKind(cls) { if (cls.kind === 'unknown') return 'none'; 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) { switch (schedule) { case 'off': return _('Disabled'); case 'hourly': return _('Hourly (minute chosen per device to avoid a fleet-wide stampede)'); case 'daily': return _('Daily, once in a randomized night window (00:00-05:59), per device'); case 'weekly': return _('Weekly, once in a randomized night window, per device'); case 'cron': return _('Custom cron: %s').format(cronExpr || '-'); default: return schedule || '-'; } } function gbFormatDate(iso) { var d; if (!iso) return _('never'); d = new Date(iso); return isNaN(d.getTime()) ? iso : d.toLocaleString(); } // View models are plain data: render() builds the first paint from them // (its tree is not attached to document yet), the apply*Dom methods push // later poll results into the live DOM by id. function gbStatusView(status, logRes, serviceRes, schedule, cronExpr) { 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), resultText: cls.message, resultDotClass: 'gitbackup-dot gitbackup-dot-' + gbOutcomeKind(cls), 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; if (!commit) return { shaText: _('No commits yet'), shaHref: null, timeText: '-' }; return { shaText: commit.sha.substring(0, 12), shaHref: common.commitUrl(remoteUrl, provider, commit.sha), timeText: common.commitTime(commit.subject) || commit.subject || '-' }; } // A failed or missing answer must read as "could not check", never "all saved". 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.'), kind: 'warn' }; if (res.differs) return { text: _('The configuration has changed since the last commit.'), kind: 'warn' }; return { text: _('The configuration matches the last commit.'), kind: 'ok' }; } function gbRenderBanner(text) { return E('div', { 'class': 'alert-message error' }, [ E('p', {}, [ text ]) ]); } // @container, not @media: the sidebar eats width a media query cannot see. var GB_CSS = [ '.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 { 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