Files
github-auto-sync-golang/static/app.js
T

513 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const $ = (sel) => document.querySelector(sel);
const STATUS = {
ok: { text: '正常', cls: 'st-ok' },
error: { text: '异常', cls: 'st-error' },
conflict: { text: '冲突', cls: 'st-conflict' },
running: { text: '同步中', cls: 'st-running' },
idle: { text: '未启动', cls: 'st-idle' },
};
const RESULT_TEXT = { ok: '成功', error: '失败', conflict: '冲突' };
let repos = [];
let current = null;
let creating = false;
let chart = null;
// ---------- 基础 ----------
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
async function api(url, opts) {
const res = await fetch(url, opts);
if (res.status === 401) {
showLogin();
throw new Error('需要登录');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
const post = (url) => api(url, { method: 'POST' });
// ---------- 渲染 ----------
function render() {
const grid = $('#grid');
grid.innerHTML = '';
repos.forEach(r => grid.appendChild(card(r)));
$('#empty').hidden = repos.length > 0;
}
function card(r) {
const st = STATUS[r.status] || STATUS.idle;
const el = document.createElement('div');
el.className = 'card ' + st.cls;
el.dataset.name = r.name;
const last = r.last_sync
? `${r.last_sync.time.slice(5, 16)} · ${RESULT_TEXT[r.last_sync.result] || r.last_sync.result}`
: '从未同步';
el.innerHTML = `
<div class="card-head">
<span class="dot"></span>
<h3>${esc(r.name)}</h3>
<span class="mode">${esc(r.mode)}</span>
</div>
<div class="path" title="${esc(r.repo_path)}">${esc(r.repo_path)}</div>
<div class="badges">
<span class="badge">${esc(r.branch)}</span>
<span class="badge ${r.ahead ? 'b-up' : ''}">领先 ↑${r.ahead}</span>
<span class="badge ${r.behind ? 'b-down' : ''}">落后 ↓${r.behind}</span>
${r.dirty ? `<span class="badge b-dirty">改动 ${r.dirty}</span>` : ''}
${r.auto_commit ? '<span class="badge">自动提交</span>' : ''}
${r.cron ? `<span class="badge" title="${esc(r.cron)}">cron</span>` : ''}
${r.conflicts && r.conflicts.length ? `<span class="badge b-conflict">冲突 ${r.conflicts.length}</span>` : ''}
${r.push_conflict ? `<span class="badge b-conflict">推送冲突</span>` : ''}
</div>
<div class="last">上次同步:${esc(last)}</div>
${r.last_sync && r.last_sync.detail ? `<div class="last-detail" title="${esc(r.last_sync.detail)}">${esc(r.last_sync.detail)}</div>` : ''}
${r.status === 'error' && r.last_error ? `<div class="err" title="${esc(r.last_error)}">${esc(r.last_error)}</div>` : ''}
<div class="card-btns">
<button class="btn" data-act="sync" ${r.running ? '' : 'disabled'}>立即同步</button>
<button class="btn" data-act="toggle">${r.running ? '暂停' : '启动'}</button>
<button class="btn" data-act="detail">详情</button>
</div>`;
return el;
}
$('#grid').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-act]');
if (!btn) return;
const name = btn.closest('.card').dataset.name;
const act = btn.dataset.act;
try {
if (act === 'sync') {
await post(`/api/repos/${name}/sync`);
} else if (act === 'toggle') {
const r = repos.find(x => x.name === name);
await post(`/api/repos/${name}/${r.running ? 'stop' : 'start'}`);
} else if (act === 'detail') {
openDetail(name);
}
} catch (err) { alert(err.message); }
});
// ---------- SSE 实时事件 ----------
function connect() {
const es = new EventSource('/api/events');
es.onopen = () => setConn(true);
es.onerror = () => setConn(false); // EventSource 会自动重连
es.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.type === 'snapshot') {
repos = msg.data || [];
render();
} else if (msg.type === 'status') {
const i = repos.findIndex(x => x.name === msg.repo);
if (i >= 0) repos[i] = msg.data; else repos.push(msg.data);
render();
updateConflictActions();
} else if (msg.type === 'log') {
appendLiveLine(msg);
if (current === msg.repo && !$('#tab-log').hidden) appendLogLine(`[${msg.time}] ${msg.msg}`);
}
};
}
function setConn(ok) {
const el = $('#conn');
el.textContent = ok ? '● 已连接' : '● 未连接';
el.className = 'conn ' + (ok ? 'on' : 'off');
const live = $('#live-conn');
if (live) {
live.textContent = ok ? '● 已连接' : '● 未连接';
live.className = 'conn ' + (ok ? 'on' : 'off');
}
}
// ---------- 全局实时活动面板 ----------
const LIVE_MAX = 400;
function appendLiveLine(msg) {
const box = $('#live-log');
if (!box) return;
let level = 'info';
if (msg.level === 'error') level = 'err';
else if (msg.level === 'warn') level = 'warn';
const line = document.createElement('div');
line.className = 'live-line lv-' + level;
line.innerHTML = `<span class="ll-time">${esc(msg.time)}</span><span class="ll-repo">${esc(msg.repo)}</span><span class="ll-msg">${esc(msg.msg)}</span>`;
box.appendChild(line);
while (box.childElementCount > LIVE_MAX) box.removeChild(box.firstChild);
box.scrollTop = box.scrollHeight;
}
function clearLive() {
const box = $('#live-log');
if (box) box.innerHTML = '';
}
// 载入各仓库最近日志作为初始上下文,避免面板一开始空白(仅启动播种一次)
let liveSeeded = false;
async function seedLive() {
if (liveSeeded) return;
liveSeeded = true;
const names = repos.map(r => r.name);
await Promise.all(names.map(async (name) => {
try {
const d = await api(`/api/repos/${name}/log?lines=8`);
if (d.lines && d.lines.length) {
d.lines.forEach(t => {
const m = t.match(/^\[(.+?)\] (.*)$/);
appendLiveLine({ time: m ? m[1] : '', repo: name, msg: m ? m[2] : t, level: 'info', seed: true });
});
}
} catch (_) { /* 忽略 */ }
}));
}
$('#btn-live-clear').addEventListener('click', clearLive);
// ---------- 登录 ----------
function showLogin() {
const el = $('#login');
if (el.hidden) {
el.hidden = false;
setTimeout(() => $('#login-password').focus(), 50);
}
}
async function doLogin() {
const errEl = $('#login-err');
errEl.hidden = true;
try {
await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: $('#login-password').value })
}).then(async res => {
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.error || '登录失败');
}
});
location.reload();
} catch (err) {
errEl.textContent = err.message;
errEl.hidden = false;
}
}
$('#btn-login').addEventListener('click', doLogin);
$('#login-password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
// ---------- 详情弹窗 ----------
function showTab(t) {
document.querySelectorAll('#m-tabs .tab').forEach(b => b.classList.toggle('active', b.dataset.tab === t));
document.querySelectorAll('.tab-pane').forEach(p => { p.hidden = p.id !== 'tab-' + t; });
if (t === 'log' && current) loadLog(current);
if (t === 'history' && current) loadHistory(current);
if (t === 'conflict' && current) { loadConflicts(current); updateConflictActions(); }
if (t === 'stats' && current) loadStats(current);
}
$('#m-tabs').addEventListener('click', (e) => {
const b = e.target.closest('.tab');
if (b) showTab(b.dataset.tab);
});
function openDetail(name) {
creating = false;
current = name;
const r = repos.find(x => x.name === name);
$('#m-title').textContent = name;
$('#f-name').disabled = true;
$('#btn-delete').hidden = false;
fillForm(r);
showTab('log');
$('#modal').hidden = false;
}
function openCreate() {
creating = true;
current = null;
$('#m-title').textContent = '新建配置';
$('#f-name').disabled = false;
$('#btn-delete').hidden = true;
fillForm(null);
showTab('config');
$('#modal').hidden = false;
}
function closeModal() {
$('#modal').hidden = true;
current = null;
}
$('#m-close').addEventListener('click', closeModal);
$('#modal').addEventListener('click', (e) => { if (e.target === $('#modal')) closeModal(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
// ---------- 日志 ----------
function loadLog(name) {
$('#logbox').textContent = '加载中…';
api(`/api/repos/${name}/log?lines=500`).then(d => {
$('#logbox').textContent = d.lines && d.lines.length ? d.lines.join('\n') : '(暂无日志)';
$('#logbox').scrollTop = $('#logbox').scrollHeight;
}).catch(err => { $('#logbox').textContent = '日志加载失败:' + err.message; });
}
function appendLogLine(line) {
const el = $('#logbox');
if (el.textContent === '(暂无日志)') el.textContent = '';
el.textContent += (el.textContent ? '\n' : '') + line;
el.scrollTop = el.scrollHeight;
}
// ---------- 提交历史 ----------
function loadHistory(name) {
const list = $('#commit-list');
list.textContent = '加载中…';
api(`/api/repos/${name}/commits?limit=50`).then(commits => {
if (!commits.length) {
list.textContent = '暂无提交记录。';
return;
}
list.innerHTML = commits.map(c => `
<div class="commit" data-hash="${esc(c.hash)}">
<div class="commit-head">
<span class="commit-hash">${esc(c.hash.slice(0, 8))}</span>
<span class="commit-subject" title="${esc(c.subject)}">${esc(c.subject)}</span>
<span class="commit-meta">${esc(c.author)} · ${esc(c.date)}</span>
</div>
<pre class="commit-diff" hidden></pre>
</div>`).join('');
}).catch(err => { list.textContent = '提交历史加载失败:' + err.message; });
}
$('#commit-list').addEventListener('click', async (e) => {
const head = e.target.closest('.commit-head');
if (!head || !current) return;
const item = head.closest('.commit');
const pre = item.querySelector('.commit-diff');
if (!pre.hidden) { pre.hidden = true; return; }
pre.hidden = false;
pre.textContent = '加载中…';
try {
const d = await api(`/api/repos/${current}/commits/${item.dataset.hash}`);
pre.textContent = d.diff || '(无差异内容)';
} catch (err) {
pre.textContent = '加载失败:' + err.message;
}
});
// ---------- 冲突 ----------
function loadConflicts(name) {
api(`/api/repos/${name}/conflicts`).then(files => {
const list = $('#conflict-list');
if (!files.length) {
list.innerHTML = '<p style="color:var(--muted)">当前没有冲突。</p>';
return;
}
list.innerHTML = files.map(f =>
`<div class="conflict-file"><h4>${esc(f.file)}</h4><pre>${esc(f.diff) || '(无差异内容)'}</pre></div>`
).join('');
}).catch(err => { $('#conflict-list').innerHTML = `<p style="color:var(--red)">${esc(err.message)}</p>`; });
}
async function doAction(action, okMsg) {
try {
await api(`/api/repos/${current}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
});
alert(okMsg);
loadConflicts(current);
} catch (err) { alert(err.message); }
}
$('#btn-abort').addEventListener('click', () => {
if (confirm('确定放弃本次合并/变基?仓库将回到冲突前状态。')) doAction('abort', '已放弃本次合并');
});
$('#btn-resolved').addEventListener('click', () => doAction('resolve', '冲突处理流程完成'));
// 手动拉取 / 推送 / 获取(B1:后端 ManualGit 已支持)
$('#btn-fetch').addEventListener('click', () => doAction('fetch', '获取完成'));
$('#btn-pull').addEventListener('click', () => doAction('pull', '拉取完成'));
$('#btn-push').addEventListener('click', () => doAction('push', '推送完成'));
// 推送冲突(non-fast-forward)的解决入口(B3)
$('#btn-force-push').addEventListener('click', async () => {
if (!current) return;
try {
await post(`/api/repos/${current}/force-push`);
alert('已发起强制推送(--force-with-lease)');
updateConflictActions();
} catch (err) { alert(err.message); }
});
$('#btn-abort-push').addEventListener('click', async () => {
if (!current) return;
try {
await post(`/api/repos/${current}/abort-push`);
alert('已放弃强制推送');
updateConflictActions();
} catch (err) { alert(err.message); }
});
// 根据当前仓库快照切换推送冲突操作区显隐(B3)
function updateConflictActions() {
const r = repos.find(x => x.name === current);
const box = $('#push-conflict-actions');
const hint = $('#push-conflict-hint');
if (!box) return;
if (r && r.push_conflict) {
box.hidden = false;
if (hint) hint.textContent = `远程 ${r.push_conflict_remote || ''} 分支 ${r.push_conflict_branch || ''} 有新提交,推送被拒绝`;
} else {
box.hidden = true;
}
}
// ---------- 统计 ----------
function dateStr(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function loadStats(name) {
api(`/api/repos/${name}/stats`).then(rows => {
const days = [];
for (let i = 29; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
days.push(dateStr(d));
}
// 后端写入的 result 词汇是 success/failed/conflict,这里归一化到 ok/error/conflict 再统计
const normResult = r => r.result === 'success' ? 'ok' : r.result === 'failed' ? 'error' : r.result;
const mk = (key) => days.map(d => rows.filter(r => normResult(r) === key && r.time.slice(0, 10) === d).length);
const total = { ok: 0, error: 0, conflict: 0 };
rows.forEach(r => { const k = normResult(r); if (total[k] !== undefined) total[k]++; });
$('#stat-summary').innerHTML =
`<span>近 30 天共 <b>${rows.length}</b> 次同步记录</span>` +
`<span style="color:var(--green)">成功 <b>${total.ok}</b></span>` +
`<span style="color:var(--red)">失败 <b>${total.error}</b></span>` +
`<span style="color:var(--amber)">冲突 <b>${total.conflict}</b></span>`;
if (chart) { chart.destroy(); chart = null; }
if (!window.Chart) return;
chart = new Chart($('#stat-chart'), {
type: 'bar',
data: {
labels: days.map(d => d.slice(5)),
datasets: [
{ label: '成功', data: mk('ok'), backgroundColor: '#97c459' },
{ label: '失败', data: mk('error'), backgroundColor: '#f09595' },
{ label: '冲突', data: mk('conflict'), backgroundColor: '#ef9f27' },
]
},
options: {
responsive: true,
scales: {
x: { stacked: true },
y: { stacked: true, ticks: { precision: 0 } }
},
plugins: { legend: { position: 'bottom' } }
}
});
}).catch(err => { $('#stat-summary').textContent = '统计加载失败:' + err.message; });
}
// ---------- 配置表单 ----------
function fillForm(r) {
$('#f-name').value = r ? r.name : '';
$('#f-path').value = r ? r.repo_path : '';
$('#f-branch').value = r ? r.branch : 'main';
$('#f-mode').value = r ? r.mode : 'both';
$('#f-remotes').value = r && r.remotes ? r.remotes.map(x => x.name + '=' + x.url).join('\n') : '';
$('#f-interval').value = r ? r.interval : 60;
$('#f-auth').value = r ? r.auth_method : 'ssh';
$('#f-conflict').value = r ? r.conflict_strategy : 'merge';
$('#f-push').value = r ? r.push_strategy : 'manual';
$('#f-retry').value = r ? r.retry_count : 3;
$('#f-retry-interval').value = r ? r.retry_interval : 5;
$('#f-autocommit').checked = r ? !!r.auto_commit : false;
$('#f-cron').value = r ? (r.cron || '') : '';
$('#f-log').value = r ? (r.log_file || '') : '';
}
function readForm() {
const remotes = [];
$('#f-remotes').value.split('\n').forEach(line => {
line = line.trim();
if (!line) return;
const i = line.indexOf('=');
if (i > 0) remotes.push({ name: line.slice(0, i).trim(), url: line.slice(i + 1).trim() });
});
return {
name: $('#f-name').value.trim(),
repo_path: $('#f-path').value.trim(),
branch: $('#f-branch').value.trim() || 'main',
remotes,
mode: $('#f-mode').value,
interval: parseInt($('#f-interval').value, 10) || 60,
auth_method: $('#f-auth').value,
retry_count: parseInt($('#f-retry').value, 10) || 0,
retry_interval: parseInt($('#f-retry-interval').value, 10) || 0,
conflict_strategy: $('#f-conflict').value,
push_strategy: $('#f-push').value,
auto_commit: $('#f-autocommit').checked,
cron: $('#f-cron').value.trim(),
log_file: $('#f-log').value.trim(),
};
}
$('#cfg-form').addEventListener('submit', async (e) => {
e.preventDefault();
const cfg = readForm();
try {
if (creating) {
await api('/api/repos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg)
});
} else {
await api(`/api/repos/${cfg.name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg)
});
}
closeModal();
refresh();
} catch (err) { alert('保存失败:' + err.message); }
});
$('#btn-delete').addEventListener('click', async () => {
if (!current) return;
if (!confirm(`确定删除配置 "${current}" ?(不影响日志和统计文件)`)) return;
try {
await api(`/api/repos/${current}`, { method: 'DELETE' });
closeModal();
refresh();
} catch (err) { alert(err.message); }
});
// ---------- 顶部按钮 ----------
$('#btn-new').addEventListener('click', openCreate);
$('#btn-empty-new').addEventListener('click', openCreate);
$('#btn-sync-all').addEventListener('click', () => {
repos.filter(r => r.running).forEach(r => post(`/api/repos/${r.name}/sync`).catch(() => {}));
});
// ---------- 初始化 ----------
async function refresh() {
try {
const d = await api('/api/status');
repos = d.repos || [];
render();
seedLive();
} catch (e) { /* 忽略,SSE 会重试 */ }
}
refresh();
connect();
setInterval(refresh, 20000);