314 lines
10 KiB
JavaScript
314 lines
10 KiB
JavaScript
// 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 = `<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();
|