467 lines
15 KiB
JavaScript
467 lines
15 KiB
JavaScript
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>` : ''}
|
|
</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();
|
|
} 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 = '';
|
|
}
|
|
|
|
// 载入各仓库最近日志作为初始上下文,避免面板一开始空白
|
|
async function seedLive() {
|
|
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);
|
|
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', '冲突处理流程完成'));
|
|
|
|
// ---------- 统计 ----------
|
|
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));
|
|
}
|
|
const mk = (key) => days.map(d => rows.filter(r => r.result === key && r.time.slice(0, 10) === d).length);
|
|
const total = { ok: 0, error: 0, conflict: 0 };
|
|
rows.forEach(r => { if (total[r.result] !== undefined) total[r.result]++; });
|
|
$('#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-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,
|
|
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);
|