6aa5e69ee4
nová stránka /portal/incidents s title CSBot Services Portal - Incidenty menu Provoz -> Incidenty aktivní incidenty a historie posledních 100 incidentů barevné stavy: open červeně, resolved zeleně karta Aktivní incidenty a seznam Poslední incidenty na přehledu realtime napojení přes existující operations-live.js a snapshot sekce Incidenty služby v detailu služby, posledních 20 incidentů DB helpery pro service_incidents migrace/indexy pro lokální prostředí, kde tabulka ještě neexistuje
279 lines
14 KiB
JavaScript
279 lines
14 KiB
JavaScript
(() => {
|
|
if (window.CSBotOperationsLive) {
|
|
return;
|
|
}
|
|
|
|
const state = {
|
|
socket: null,
|
|
reconnectTimer: null,
|
|
reconnectDelay: 1500,
|
|
};
|
|
|
|
const escapeHtml = (value) => String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
|
|
const statusBadge = (status) => {
|
|
const value = String(status || "");
|
|
const normalized = value.toLowerCase();
|
|
const labels = {
|
|
queued: "čeká",
|
|
running: "běží",
|
|
cancelled_requested: "žádost o zrušení",
|
|
cancelled: "zrušeno",
|
|
success: "úspěšné",
|
|
failed: "selhalo",
|
|
ok: "úspěšné",
|
|
error: "chyba",
|
|
};
|
|
let className = "pill pill-muted";
|
|
if (["success", "ok", "succeeded", "done", "deployed", "completed"].includes(normalized)) {
|
|
className = "pill pill-success";
|
|
} else if (["queued", "running", "pending", "in_progress", "starting", "cancelled_requested"].includes(normalized)) {
|
|
className = "pill pill-warning";
|
|
} else if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
|
|
className = "pill pill-danger";
|
|
}
|
|
return `<span class="${className}">${escapeHtml(labels[normalized] || value)}</span>`;
|
|
};
|
|
|
|
const workerBadge = (online) => online
|
|
? '<span class="pill pill-success">online</span>'
|
|
: '<span class="pill pill-danger">offline</span>';
|
|
|
|
const healthBadge = (status) => {
|
|
const value = String(status || "");
|
|
const normalized = value.toLowerCase();
|
|
const labels = {
|
|
healthy: "zdravá",
|
|
unhealthy: "nezdravá",
|
|
unreachable: "nedostupná",
|
|
};
|
|
let className = "pill pill-muted";
|
|
if (normalized === "healthy") className = "pill pill-success";
|
|
if (normalized === "unhealthy") className = "pill pill-warning";
|
|
if (normalized === "unreachable") className = "pill pill-danger";
|
|
return `<span class="${className}">${escapeHtml(labels[normalized] || value)}</span>`;
|
|
};
|
|
|
|
const incidentBadge = (status) => {
|
|
const value = String(status || "");
|
|
const normalized = value.toLowerCase();
|
|
const labels = {
|
|
open: "otevřený",
|
|
resolved: "vyřešený",
|
|
};
|
|
let className = "pill pill-muted";
|
|
if (normalized === "open") className = "pill pill-danger";
|
|
if (normalized === "resolved") className = "pill pill-success";
|
|
return `<span class="${className}">${escapeHtml(labels[normalized] || value)}</span>`;
|
|
};
|
|
|
|
const formatDuration = (seconds) => {
|
|
if (seconds === null || seconds === undefined || seconds === "") {
|
|
return "";
|
|
}
|
|
const total = Math.max(0, Number.parseInt(seconds, 10) || 0);
|
|
const hours = Math.floor(total / 3600);
|
|
const minutes = Math.floor((total % 3600) / 60);
|
|
const secs = total % 60;
|
|
if (hours) return `${hours} h ${minutes} min`;
|
|
if (minutes) return `${minutes} min ${secs} s`;
|
|
return `${secs} s`;
|
|
};
|
|
|
|
const setText = (selector, value) => {
|
|
document.querySelectorAll(selector).forEach((el) => {
|
|
el.textContent = value ?? 0;
|
|
});
|
|
};
|
|
|
|
const renderJobs = (jobs) => {
|
|
const rows = (jobs || []).map((job) => `
|
|
<tr>
|
|
<td><strong><a href="/portal/jobs/${escapeHtml(job.id)}">#${escapeHtml(job.id)}</a></strong></td>
|
|
<td>${statusBadge(job.status)}</td>
|
|
<td>${escapeHtml(job.type)}</td>
|
|
<td>${escapeHtml(job.target_type)}: <strong>${escapeHtml(job.target_id)}</strong></td>
|
|
<td>${escapeHtml(job.source)}</td>
|
|
<td>${escapeHtml(job.created_at)}</td>
|
|
</tr>
|
|
`).join("");
|
|
return rows || '<tr><td colspan="6">Zatím nejsou evidované žádné úlohy.</td></tr>';
|
|
};
|
|
|
|
const renderWorkers = (workers) => {
|
|
const rows = (workers || []).map((worker) => {
|
|
const currentJob = worker.current_job_id === null || worker.current_job_id === undefined
|
|
? ""
|
|
: `<a href="/portal/jobs/${escapeHtml(worker.current_job_id)}">#${escapeHtml(worker.current_job_id)}</a>`;
|
|
return `
|
|
<tr data-worker-id="${escapeHtml(worker.id)}">
|
|
<td><strong><a href="/portal/workers/${encodeURIComponent(worker.id || "")}">${escapeHtml(worker.id)}</a></strong></td>
|
|
<td data-worker-field="status">${workerBadge(Boolean(worker.online))}<br><span class="muted">${escapeHtml(worker.status)}</span></td>
|
|
<td data-worker-field="last_seen_at">${escapeHtml(worker.last_seen_at)}</td>
|
|
<td data-worker-field="current_job">${currentJob}</td>
|
|
<td>${escapeHtml(worker.started_at)}</td>
|
|
<td class="actions-cell"><a class="btn" href="/portal/workers/${encodeURIComponent(worker.id || "")}">Detail</a></td>
|
|
</tr>
|
|
`;
|
|
}).join("");
|
|
return rows || '<tr><td colspan="6">Zatím nejsou evidovaní žádní workeři.</td></tr>';
|
|
};
|
|
|
|
const renderDeployments = (deployments) => {
|
|
const rows = (deployments || []).map((deployment) => `
|
|
<tr>
|
|
<td><strong><a href="/portal/deployments/${escapeHtml(deployment.id)}">#${escapeHtml(deployment.id)}</a></strong></td>
|
|
<td><a href="/portal/apps/${encodeURIComponent(deployment.app_id || "")}">${escapeHtml(deployment.app_id)}</a></td>
|
|
<td>${escapeHtml(deployment.kind)}</td>
|
|
<td>${statusBadge(deployment.status)}</td>
|
|
<td>${escapeHtml(deployment.trigger_source)}</td>
|
|
<td>${escapeHtml(deployment.started_at)}</td>
|
|
<td>${escapeHtml(deployment.finished_at)}</td>
|
|
</tr>
|
|
`).join("");
|
|
return rows || '<tr><td colspan="7">Zatím nejsou evidovaná žádná nasazení.</td></tr>';
|
|
};
|
|
|
|
const renderAudit = (events) => {
|
|
const rows = (events || []).map((event) => `
|
|
<tr>
|
|
<td>${escapeHtml(event.created_at)}</td>
|
|
<td>${escapeHtml(event.username)}</td>
|
|
<td><span class="pill pill-muted">${escapeHtml(event.action)}</span></td>
|
|
<td>${escapeHtml(event.target_type)}</td>
|
|
<td>${escapeHtml(event.target_id)}</td>
|
|
<td>${escapeHtml(event.source)}</td>
|
|
</tr>
|
|
`).join("");
|
|
return rows || '<tr><td colspan="6">Zatím nejsou evidované žádné auditní události.</td></tr>';
|
|
};
|
|
|
|
const renderHealthProblems = (items) => {
|
|
const rows = (items || []).map((item) => {
|
|
const errorText = String(item.error_text || "");
|
|
const errorPreview = errorText.length > 120 ? `${errorText.slice(0, 117)}...` : errorText;
|
|
const serviceName = item.service_name || item.service_id || "";
|
|
return `
|
|
<tr>
|
|
<td><a href="/portal/apps/${encodeURIComponent(item.service_id || "")}">${escapeHtml(serviceName)}</a></td>
|
|
<td>${healthBadge(item.status)}</td>
|
|
<td>${escapeHtml(item.checked_at)}</td>
|
|
<td>${escapeHtml(item.response_time_ms ?? "")}</td>
|
|
<td>${escapeHtml(errorPreview)}</td>
|
|
</tr>
|
|
`;
|
|
}).join("");
|
|
return rows || '<tr><td colspan="5">Žádné služby nevyžadují pozornost.</td></tr>';
|
|
};
|
|
|
|
const renderOpenIncidents = (items) => {
|
|
const rows = (items || []).map((item) => {
|
|
const serviceName = item.service_name || item.service_id || "";
|
|
const duration = formatDuration(item.duration_seconds) || "probíhá";
|
|
return `
|
|
<tr>
|
|
<td><a href="/portal/apps/${encodeURIComponent(item.service_id || "")}">${escapeHtml(serviceName)}</a></td>
|
|
<td>${escapeHtml(item.title)}</td>
|
|
<td>${escapeHtml(item.started_at)}</td>
|
|
<td>${escapeHtml(duration)}</td>
|
|
</tr>
|
|
`;
|
|
}).join("");
|
|
return rows || '<tr><td colspan="4">Žádné aktivní incidenty.</td></tr>';
|
|
};
|
|
|
|
const renderIncidents = (items) => {
|
|
const rows = (items || []).map((item) => {
|
|
const serviceName = item.service_name || item.service_id || "";
|
|
return `
|
|
<tr>
|
|
<td><a href="/portal/apps/${encodeURIComponent(item.service_id || "")}">${escapeHtml(serviceName)}</a></td>
|
|
<td>${escapeHtml(item.title)}</td>
|
|
<td>${escapeHtml(item.started_at)}</td>
|
|
<td>${escapeHtml(item.ended_at)}</td>
|
|
<td>${escapeHtml(formatDuration(item.duration_seconds))}</td>
|
|
<td>${incidentBadge(item.status)}</td>
|
|
</tr>
|
|
`;
|
|
}).join("");
|
|
return rows || '<tr><td colspan="6">Zatím nejsou evidované žádné incidenty.</td></tr>';
|
|
};
|
|
|
|
const renderSnapshot = (snapshot) => {
|
|
setText("[data-live-count='jobs.queued']", snapshot.jobs?.queued);
|
|
setText("[data-live-count='jobs.running']", snapshot.jobs?.running);
|
|
setText("[data-live-count='jobs.failed_24h']", snapshot.jobs?.failed_24h);
|
|
setText("[data-live-count='jobs.success_24h']", snapshot.jobs?.success_24h);
|
|
setText("[data-live-count='apps.active']", snapshot.apps?.active);
|
|
setText("[data-live-count='apps.problematic']", snapshot.apps?.problematic);
|
|
setText("[data-live-count='health.healthy']", snapshot.health?.healthy);
|
|
setText("[data-live-count='health.unhealthy']", snapshot.health?.unhealthy);
|
|
setText("[data-live-count='health.unreachable']", snapshot.health?.unreachable);
|
|
setText("[data-live-count='incidents.open']", snapshot.incidents?.open);
|
|
setText("[data-live-count='workers.online']", snapshot.workers?.online);
|
|
setText("[data-live-count='workers.offline']", snapshot.workers?.offline);
|
|
setText("[data-live-count='workers.total']", (snapshot.workers?.online || 0) + (snapshot.workers?.offline || 0));
|
|
setText("[data-live-count='deployments.running']", snapshot.deployments?.running);
|
|
setText("[data-live-count='deployments.failed_24h']", snapshot.deployments?.failed_24h);
|
|
setText("[data-live-count='deployments.success_24h']", snapshot.deployments?.success_24h);
|
|
setText("[data-live-count='deployments.today']", snapshot.deployments?.today);
|
|
|
|
document.querySelectorAll("[data-live-table='jobs.recent']").forEach((el) => { el.innerHTML = renderJobs(snapshot.jobs?.recent); });
|
|
document.querySelectorAll("[data-live-table='jobs.failed']").forEach((el) => { el.innerHTML = renderJobs(snapshot.jobs?.recent_failed); });
|
|
document.querySelectorAll("[data-live-table='workers.recent']").forEach((el) => { el.innerHTML = renderWorkers(snapshot.workers?.recent); });
|
|
document.querySelectorAll("[data-live-table='deployments.recent']").forEach((el) => { el.innerHTML = renderDeployments(snapshot.deployments?.recent); });
|
|
document.querySelectorAll("[data-live-table='deployments.failed']").forEach((el) => { el.innerHTML = renderDeployments(snapshot.deployments?.recent_failed); });
|
|
document.querySelectorAll("[data-live-table='audit.recent']").forEach((el) => { el.innerHTML = renderAudit(snapshot.audit?.recent); });
|
|
document.querySelectorAll("[data-live-table='health.problems']").forEach((el) => { el.innerHTML = renderHealthProblems(snapshot.health?.problems); });
|
|
document.querySelectorAll("[data-live-table='incidents.active']").forEach((el) => { el.innerHTML = renderOpenIncidents(snapshot.incidents?.active); });
|
|
document.querySelectorAll("[data-live-table='incidents.recent']").forEach((el) => { el.innerHTML = renderIncidents(snapshot.incidents?.recent); });
|
|
|
|
(snapshot.workers?.recent || []).forEach((worker) => {
|
|
const row = Array.from(document.querySelectorAll("tr[data-worker-id]"))
|
|
.find((item) => item.dataset.workerId === String(worker.id || ""));
|
|
if (!row) {
|
|
return;
|
|
}
|
|
const currentJob = worker.current_job_id === null || worker.current_job_id === undefined
|
|
? ""
|
|
: `<a href="/portal/jobs/${escapeHtml(worker.current_job_id)}">#${escapeHtml(worker.current_job_id)}</a>`;
|
|
const statusCell = row.querySelector("[data-worker-field='status']");
|
|
const lastSeenCell = row.querySelector("[data-worker-field='last_seen_at']");
|
|
const currentJobCell = row.querySelector("[data-worker-field='current_job']");
|
|
if (statusCell) statusCell.innerHTML = `${workerBadge(Boolean(worker.online))}<br><span class="muted">${escapeHtml(worker.status)}</span>`;
|
|
if (lastSeenCell) lastSeenCell.textContent = worker.last_seen_at || "";
|
|
if (currentJobCell) currentJobCell.innerHTML = currentJob;
|
|
});
|
|
};
|
|
|
|
const connect = () => {
|
|
clearTimeout(state.reconnectTimer);
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
state.socket = new WebSocket(`${protocol}//${window.location.host}/portal/ws/operations`);
|
|
|
|
state.socket.addEventListener("message", (event) => {
|
|
const snapshot = JSON.parse(event.data);
|
|
if (snapshot.type !== "operations.snapshot") {
|
|
return;
|
|
}
|
|
window.dispatchEvent(new CustomEvent("operations:snapshot", { detail: snapshot }));
|
|
renderSnapshot(snapshot);
|
|
});
|
|
|
|
state.socket.addEventListener("close", () => {
|
|
state.reconnectTimer = setTimeout(connect, state.reconnectDelay);
|
|
});
|
|
state.socket.addEventListener("error", () => {
|
|
state.socket.close();
|
|
});
|
|
};
|
|
|
|
window.CSBotOperationsLive = { connect };
|
|
connect();
|
|
})();
|