Files
2026-09-15 14:26:37 +08:00

314 lines
10 KiB
JavaScript
Raw Permalink 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.
// GitLab Webhook Dashboard - 前端逻辑
// 数据源:
// GET /api/queue -> { pending: [], current: { task, startedAt, log } | null }
// GET /api/history -> { scripts: [], history: { scriptPath: [record, ...] } }
const LOG_TAIL_CHARS = 12000; // 前端日志渲染尾部最大字符数
const state = {
pending: [],
current: null,
history: {}, // { scriptPath: [record, ...] }
scripts: [], // 配置中的全部脚本
autoscroll: true,
expandedLogs: new Set(), // 已展开的历史日志 record IDre-render 后恢复)
lastLiveKey: null, // 上次渲染的"当前任务身份"(用于判断是否需要重建 DOM)
modalOpen: false, // 实时日志弹窗是否打开
};
// ============ Helpers ============
const $ = (sel) => document.querySelector(sel);
const fmtTime = (iso) => {
if (!iso) return "-";
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
const fmtFullTime = (iso) => {
if (!iso) return "-";
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
const shortCommit = (s) => {
if (!s) return "unknown";
return s.length > 7 ? s.slice(0, 7) : s;
};
const escapeHtml = (s) =>
String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
// ============ Connection status ============
function setConn(ok, text) {
const dot = $("#conn-dot");
const txt = $("#conn-text");
dot.classList.remove("dot-green", "dot-red", "dot-grey");
dot.classList.add(ok ? "dot-green" : "dot-red");
txt.textContent = text;
}
// ============ Fetchers ============
async function fetchJSON(url) {
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
async function pollQueue() {
try {
const data = await fetchJSON("/api/queue");
state.pending = data.pending || [];
state.current = data.current || null;
setConn(true, "已连接");
renderQueue();
renderRunning();
if (state.modalOpen) refreshModalLog();
} catch (e) {
setConn(false, "连接失败");
console.warn("queue poll failed:", e);
}
}
async function pollHistory() {
try {
const data = await fetchJSON("/api/history");
state.scripts = data.scripts || [];
state.history = data.history || {};
renderHistory();
} catch (e) {
console.warn("history poll failed:", e);
}
}
// ============ Renderers ============
function renderQueue() {
const list = $("#pending-list");
const badge = $("#pending-count");
badge.textContent = String(state.pending.length);
if (state.pending.length === 0) {
list.innerHTML = `<p class="empty">暂无等待任务</p>`;
return;
}
list.innerHTML = state.pending.map((p) => `
<div class="pending-item">
<div class="row">
<strong>${escapeHtml(p.repo)}</strong>
<span class="badge badge-warn">等待中</span>
</div>
<div class="row meta">
<span>分支 <code>${escapeHtml(p.ref)}</code></span>
<span>commit <code>${escapeHtml(shortCommit(p.commit))}</code></span>
</div>
<div class="script">${escapeHtml(p.script)}</div>
<div class="meta">入队时间: ${escapeHtml(fmtFullTime(p.queuedAt))}</div>
</div>
`).join("");
}
function renderRunning() {
const body = $("#running-body");
const stateBadge = $("#running-state");
if (!state.current) {
stateBadge.textContent = "空闲";
stateBadge.className = "badge badge-grey";
body.innerHTML = `<p class="empty">当前没有任务在执行</p>`;
state.lastLiveKey = null;
return;
}
const c = state.current;
stateBadge.textContent = "执行中";
stateBadge.className = "badge badge-warn";
const liveKey = `${c.task.script}|${c.task.repo}|${c.task.ref}|${c.startedAt}`;
if (state.lastLiveKey === liveKey) return;
state.lastLiveKey = liveKey;
body.innerHTML = `
<dl class="running-meta">
<dt>仓库</dt><dd>${escapeHtml(c.task.repo)}</dd>
<dt>分支</dt><dd>${escapeHtml(c.task.ref)}</dd>
<dt>Commit</dt><dd>${escapeHtml(shortCommit(c.task.commit))}</dd>
<dt>脚本</dt><dd>${escapeHtml(c.task.script)}</dd>
<dt>开始</dt><dd>${escapeHtml(fmtFullTime(c.startedAt))}</dd>
</dl>
<div class="running-actions">
<button id="open-log-btn" class="btn-ghost" type="button">查看实时日志</button>
</div>
`;
$("#open-log-btn").addEventListener("click", openLogModal);
}
// ============ Live log modal ============
function openLogModal() {
if (!state.current) return;
$("#log-modal").hidden = false;
state.modalOpen = true;
refreshModalLog();
}
function closeLogModal() {
$("#log-modal").hidden = true;
state.modalOpen = false;
}
function refreshModalLog() {
const logBox = $("#modal-log");
const sizeEl = $("#log-size");
if (!logBox) return;
const fullLog = state.current ? (state.current.log || "") : "";
const tail = fullLog.length > LOG_TAIL_CHARS
? fullLog.slice(fullLog.length - LOG_TAIL_CHARS)
: fullLog;
// 仅当贴底或已开启自动滚动时才追尾,避免用户向上滚动时被打断
const distanceFromBottom = logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight;
const atBottom = distanceFromBottom <= 24;
logBox.textContent = tail || "(等待脚本输出…)";
sizeEl.textContent = fullLog.length
? `${(fullLog.length / 1024).toFixed(1)} KB`
: "";
if (state.autoscroll && atBottom) {
requestAnimationFrame(() => {
logBox.scrollTop = logBox.scrollHeight;
});
}
}
function statusPill(status) {
const map = {
success: ["status-success", "成功"],
failed: ["status-failed", "失败"],
timeout: ["status-timeout", "超时"],
skipped: ["status-skipped", "跳过"],
running: ["status-running", "运行中"],
};
const [cls, text] = map[status] || ["status-skipped", status || "-"];
return `<span class="status-pill ${cls}"><span class="dot"></span>${text}</span>`;
}
function renderHistory() {
const root = $("#history-list");
if (!state.scripts.length) {
root.innerHTML = `<p class="empty">配置文件中未声明任何脚本</p>`;
return;
}
// 当前正在执行的任务 ID(用于高亮)
const liveKey = state.current
? `${state.current.task.script}|${state.current.task.repo}|${state.current.task.ref}`
: null;
root.innerHTML = state.scripts.map((script) => {
const records = state.history[script] || [];
const rows = records.length
? records.slice().reverse().map((r) => {
const liveMarker = (liveKey && liveKey === `${r.script}|${r.repo}|${r.ref}` && r.status === "running")
? "row-running"
: "";
const logId = `log-${r.id}`;
return `
<tr class="${liveMarker}">
<td>${escapeHtml(fmtFullTime(r.startedAt))}</td>
<td>${escapeHtml(shortCommit(r.commit))}</td>
<td>${escapeHtml(r.repo)} <span class="muted">${escapeHtml(r.ref)}</span></td>
<td>${statusPill(r.status)}</td>
<td>${escapeHtml(r.duration || (r.status === "running" ? "进行中" : "-"))}</td>
<td>${escapeHtml(r.reason || "")}</td>
<td>
${r.log
? `<button class="toggle-log" data-target="${logId}" data-id="${r.id}" type="button">查看日志</button>`
: `<span class="muted small">无日志</span>`}
</td>
</tr>
${r.log ? `
<tr class="log-cell">
<td colspan="7" style="padding:0;">
<pre class="log-box" id="${logId}" style="display:none; max-height:280px;">${escapeHtml(r.log)}</pre>
</td>
</tr>` : ""}
`;
}).join("")
: `<tr><td colspan="7" class="empty">尚无执行记录</td></tr>`;
return `
<div class="history-group">
<h3><code>${escapeHtml(script)}</code><span class="muted small">(${records.length})</span></h3>
<table class="history-table">
<thead>
<tr>
<th>开始时间</th><th>Commit</th><th>仓库/分支</th>
<th>状态</th><th>耗时</th><th>原因</th><th>日志</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}).join("");
// 恢复展开状态(re-render 后保持已展开的日志)
root.querySelectorAll(".toggle-log").forEach((btn) => {
const expanded = state.expandedLogs.has(btn.dataset.id);
btn.textContent = expanded ? "收起日志" : "查看日志";
const t = document.getElementById(btn.dataset.target);
if (t) t.style.display = expanded ? "block" : "none";
});
// 绑定展开日志按钮(以 state.expandedLogs 为单一事实源)
root.querySelectorAll(".toggle-log").forEach((btn) => {
btn.addEventListener("click", () => {
const target = document.getElementById(btn.dataset.target);
if (!target) return;
const id = btn.dataset.id;
if (state.expandedLogs.has(id)) {
state.expandedLogs.delete(id);
target.style.display = "none";
btn.textContent = "查看日志";
} else {
state.expandedLogs.add(id);
target.style.display = "block";
btn.textContent = "收起日志";
}
});
});
}
// ============ Wiring ============
$("#refresh-btn").addEventListener("click", () => {
pollQueue();
pollHistory();
});
$("#log-autoscroll").addEventListener("click", (e) => {
state.autoscroll = !state.autoscroll;
e.currentTarget.classList.toggle("active", state.autoscroll);
if (state.autoscroll) {
const logBox = $("#modal-log");
if (logBox) {
requestAnimationFrame(() => {
logBox.scrollTop = logBox.scrollHeight;
});
}
}
});
// 弹窗关闭:点击 backdrop / 关闭按钮 / Esc
document.querySelectorAll("#log-modal [data-close]").forEach((el) => {
el.addEventListener("click", closeLogModal);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && state.modalOpen) closeLogModal();
});
pollQueue();
pollHistory();