// 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 ID(re-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) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
}[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 = `
暂无等待任务
`;
return;
}
list.innerHTML = state.pending.map((p) => `
${escapeHtml(p.repo)}
等待中
分支 ${escapeHtml(p.ref)}
commit ${escapeHtml(shortCommit(p.commit))}
${escapeHtml(p.script)}
入队时间: ${escapeHtml(fmtFullTime(p.queuedAt))}
`).join("");
}
function renderRunning() {
const body = $("#running-body");
const stateBadge = $("#running-state");
if (!state.current) {
stateBadge.textContent = "空闲";
stateBadge.className = "badge badge-grey";
body.innerHTML = `当前没有任务在执行
`;
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 = `
- 仓库
- ${escapeHtml(c.task.repo)}
- 分支
- ${escapeHtml(c.task.ref)}
- Commit
- ${escapeHtml(shortCommit(c.task.commit))}
- 脚本
- ${escapeHtml(c.task.script)}
- 开始
- ${escapeHtml(fmtFullTime(c.startedAt))}
`;
$("#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 `${text}`;
}
function renderHistory() {
const root = $("#history-list");
if (!state.scripts.length) {
root.innerHTML = `配置文件中未声明任何脚本
`;
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 `
| ${escapeHtml(fmtFullTime(r.startedAt))} |
${escapeHtml(shortCommit(r.commit))} |
${escapeHtml(r.repo)} ${escapeHtml(r.ref)} |
${statusPill(r.status)} |
${escapeHtml(r.duration || (r.status === "running" ? "进行中" : "-"))} |
${escapeHtml(r.reason || "")} |
${r.log
? ``
: `无日志`}
|
${r.log ? `
${escapeHtml(r.log)}
|
` : ""}
`;
}).join("")
: `| 尚无执行记录 |
`;
return `
${escapeHtml(script)}(${records.length})
| 开始时间 | Commit | 仓库/分支 |
状态 | 耗时 | 原因 | 日志 |
${rows}
`;
}).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();