Files
App/www/monitoring.js
HiepLM 77ea3cc361
Some checks failed
Test / test (push) Has been cancelled
Thêm phần 5. Monitoring và 6. System
2026-06-25 16:00:18 +07:00

928 lines
36 KiB
JavaScript

(() => {
const PAGE_SIZE = 10;
const POLL_MS = 2500;
const ICONS = {
mission: `<svg class="monMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><rect x="4" y="5" width="14" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M7 9h8M7 12h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
action: `<svg class="monMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11 7v4l3 2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
view: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M1 7s2.5-4 6-4 6 4 6 4-2.5 4-6 4-6-4-6-4z" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="7" cy="7" r="1.8" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>`,
download: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 2v7M4 7l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M2 11h10" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
};
const VIEW_IDS = [
"monitoringAnalyticsView",
"monitoringSystemLogView",
"monitoringErrorLogsView",
"monitoringHardwareView",
"monitoringSafetyView",
"monitoringPlaceholderView",
"monitoringMissionLogView",
"monitoringActionLogView",
];
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const store = {
section: "analytics",
view: "analytics",
queue: [],
runner: {},
runs: [],
runActions: new Map(),
selectedEntryId: null,
missionFilter: "",
missionPage: 1,
actionFilter: "",
actionPage: 1,
systemLogItems: [],
systemLogFilter: "",
systemLogPage: 1,
errorLogItems: [],
errorLogFilter: "",
hardwareGroups: [],
hardwareExpanded: new Set(),
analytics: null,
analyticsChartMode: "bar",
safety: null,
pollTimer: null,
};
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function apiJson(url, opts = {}) {
const res = await fetch(url, { credentials: "include", ...opts });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
function hideAllViews() {
VIEW_IDS.forEach((id) => {
const node = el(id);
if (!node) return;
node.hidden = true;
node.setAttribute("aria-hidden", "true");
});
}
function showView(node) {
hideAllViews();
if (!node) return;
node.hidden = false;
node.removeAttribute("aria-hidden");
}
function pageCount(total) {
return Math.max(1, Math.ceil(total / PAGE_SIZE));
}
function formatTime(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function formatDateYmd(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function addDaysYmd(ymd, delta) {
const d = new Date(`${ymd}T12:00:00`);
d.setDate(d.getDate() + delta);
return formatDateYmd(d);
}
function startPoll(fn) {
stopPoll();
store.pollTimer = window.setInterval(() => {
fn().catch(() => {});
}, POLL_MS);
}
function stopPoll() {
if (store.pollTimer) {
window.clearInterval(store.pollTimer);
store.pollTimer = null;
}
}
function stateDotClass(state) {
if (state === "error") return "monMirStateDot--error";
if (state === "warn") return "monMirStateDot--warn";
if (state === "info") return "monMirStateDot--info";
return "monMirStateDot--ok";
}
function hwStatusClass(status) {
if (status === "error") return "monHwStatus--error";
if (status === "warn") return "monHwStatus--warn";
return "monHwStatus--ok";
}
// ——— Analytics ———
function initAnalyticsPresets() {
const wrap = el("analyticsPresets");
if (!wrap) return;
const presets = [
{ key: "week", label: "monitoring.analytics.presetWeek", days: -6 },
{ key: "7d", label: "monitoring.analytics.preset7d", days: -6 },
{ key: "30d", label: "monitoring.analytics.preset30d", days: -29 },
{ key: "365d", label: "monitoring.analytics.preset365d", days: -364 },
];
wrap.innerHTML = presets
.map(
(p) =>
`<button type="button" class="mapsMirBtn mapsMirBtn--outline monAnalyticsPresetBtn" data-preset-days="${p.days}" data-i18n="${p.label}">${t(p.label)}</button>`,
)
.join("");
}
function setAnalyticsDateRange(start, end) {
if (el("analyticsStartDate")) el("analyticsStartDate").value = start;
if (el("analyticsEndDate")) el("analyticsEndDate").value = end;
}
async function refreshAnalytics() {
const start = el("analyticsStartDate")?.value || "";
const end = el("analyticsEndDate")?.value || "";
const grouping = el("analyticsGrouping")?.value || "day";
const qs = new URLSearchParams();
if (start) qs.set("start", start);
if (end) qs.set("end", end);
if (grouping) qs.set("grouping", grouping);
store.analytics = await apiJson(`/api/monitoring/analytics?${qs}`);
renderAnalyticsChart();
}
function renderAnalyticsChart() {
const data = store.analytics;
const svg = el("analyticsChart");
const empty = el("analyticsChartEmpty");
const totalEl = el("analyticsTotalLabel");
if (!svg || !data) return;
const buckets = Array.isArray(data.buckets) ? data.buckets : [];
const mode = el("analyticsChartMode")?.value || store.analyticsChartMode || "bar";
store.analyticsChartMode = mode;
if (totalEl) {
totalEl.textContent = t("monitoring.analytics.totalPeriod", {
n: (data.total_meters || 0).toFixed(1),
lifetime: (data.lifetime_meters || 0).toFixed(1),
});
}
if (empty) empty.hidden = buckets.length > 0;
svg.hidden = buckets.length === 0;
if (!buckets.length) {
svg.innerHTML = "";
return;
}
const values = buckets.map((b) => (mode === "accumulated" ? b.accumulated : b.meters) || 0);
const maxVal = Math.max(...values, 1);
const padL = 48;
const padR = 16;
const padT = 16;
const padB = 40;
const w = 800;
const h = 280;
const chartW = w - padL - padR;
const chartH = h - padT - padB;
const barGap = 4;
const barW = Math.max(8, (chartW - barGap * (buckets.length - 1)) / buckets.length);
let svgBody = "";
values.forEach((val, i) => {
const barH = (val / maxVal) * chartH;
const x = padL + i * (barW + barGap);
const y = padT + chartH - barH;
const label = String(buckets[i].label || "").slice(5);
svgBody += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${barH.toFixed(1)}" class="monAnalyticsBar" rx="2"><title>${escapeHtml(buckets[i].label)}: ${val.toFixed(1)} m</title></rect>`;
svgBody += `<text x="${(x + barW / 2).toFixed(1)}" y="${h - 12}" text-anchor="middle" class="monAnalyticsLabel">${escapeHtml(label)}</text>`;
});
svgBody += `<line x1="${padL}" y1="${padT + chartH}" x2="${w - padR}" y2="${padT + chartH}" class="monAnalyticsAxis"/>`;
svgBody += `<text x="${padL - 8}" y="${padT + 8}" text-anchor="end" class="monAnalyticsLabel">${maxVal.toFixed(0)}m</text>`;
svg.innerHTML = svgBody;
}
function showAnalytics() {
store.view = "analytics";
store.section = "analytics";
showView(el("monitoringAnalyticsView"));
const today = formatDateYmd(new Date());
if (!el("analyticsStartDate")?.value) setAnalyticsDateRange(addDaysYmd(today, -6), today);
startPoll(refreshAnalytics);
void refreshAnalytics();
}
// ——— System log ———
function filteredSystemLog() {
const q = store.systemLogFilter.trim().toLowerCase();
let items = [...store.systemLogItems];
if (q) {
items = items.filter((e) => {
return (
String(e.module || "").toLowerCase().includes(q) ||
String(e.message || "").toLowerCase().includes(q) ||
String(e.state || "").toLowerCase().includes(q)
);
});
}
return items;
}
function renderSystemLog() {
const listEl = el("systemLogList");
if (!listEl) return;
const items = filteredSystemLog();
const total = items.length;
const pages = pageCount(total);
if (store.systemLogPage > pages) store.systemLogPage = pages;
const start = (store.systemLogPage - 1) * PAGE_SIZE;
const pageItems = items.slice(start, start + PAGE_SIZE);
if (el("systemLogFilterCount")) el("systemLogFilterCount").textContent = t("monitoring.itemsFound", { n: total });
if (el("systemLogPageLabel")) el("systemLogPageLabel").textContent = t("monitoring.pageOf", { page: store.systemLogPage, total: pages });
listEl.innerHTML = "";
const tableEl = el("systemLogTable");
const emptyEl = el("systemLogListEmpty");
if (tableEl) tableEl.hidden = total === 0;
if (emptyEl) {
emptyEl.hidden = total > 0;
emptyEl.textContent = store.systemLogFilter ? t("monitoring.systemLog.emptyFilter") : t("monitoring.systemLog.empty");
}
pageItems.forEach((entry) => {
const tr = document.createElement("tr");
tr.className = "monMirRow";
tr.innerHTML = `
<td><span class="monMirStateDot ${stateDotClass(entry.state)}" title="${escapeHtml(entry.state || "")}"></span></td>
<td>${escapeHtml(entry.module || "—")}</td>
<td class="monMirMessageCell">${escapeHtml(entry.message || "—")}</td>
<td>${escapeHtml(formatTime(entry.ts))}</td>`;
listEl.appendChild(tr);
});
}
async function refreshSystemLog() {
const data = await apiJson("/api/monitoring/system_log");
store.systemLogItems = Array.isArray(data.items) ? data.items : [];
renderSystemLog();
}
function showSystemLog() {
store.view = "system-log";
store.section = "monitoring-log";
showView(el("monitoringSystemLogView"));
startPoll(refreshSystemLog);
void refreshSystemLog();
}
// ——— Error logs ———
function filteredErrorLogs() {
const q = store.errorLogFilter.trim().toLowerCase();
let items = [...store.errorLogItems];
if (q) {
items = items.filter((e) => {
return (
String(e.description || "").toLowerCase().includes(q) ||
String(e.module || "").toLowerCase().includes(q)
);
});
}
return items;
}
function renderErrorLogs() {
const listEl = el("errorLogsList");
if (!listEl) return;
const items = filteredErrorLogs();
const total = items.length;
if (el("errorLogsFilterCount")) el("errorLogsFilterCount").textContent = t("monitoring.itemsFound", { n: total });
listEl.innerHTML = "";
const tableEl = el("errorLogsTable");
const emptyEl = el("errorLogsListEmpty");
if (tableEl) tableEl.hidden = total === 0;
if (emptyEl) {
emptyEl.hidden = total > 0;
emptyEl.textContent = store.errorLogFilter ? t("monitoring.errorLogs.emptyFilter") : t("monitoring.errorLogs.empty");
}
items.forEach((entry) => {
const tr = document.createElement("tr");
tr.className = "monMirRow";
tr.innerHTML = `
<td>${escapeHtml(entry.description || "—")}</td>
<td>${escapeHtml(entry.module || "—")}</td>
<td>${escapeHtml(formatTime(entry.ts))}</td>
<td class="mapsMirTdFunctions">
<a class="mapsMirIconBtn" href="/api/monitoring/error_logs/${encodeURIComponent(entry.id)}/download" download title="${escapeHtml(t("monitoring.errorLogs.download"))}">${ICONS.download}</a>
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete-error="${escapeHtml(entry.id)}" title="${escapeHtml(t("monitoring.errorLogs.delete"))}">${ICONS.delete}</button>
</td>`;
listEl.appendChild(tr);
});
}
async function refreshErrorLogs() {
const data = await apiJson("/api/monitoring/error_logs");
store.errorLogItems = Array.isArray(data.items) ? data.items : [];
renderErrorLogs();
}
function showErrorLogs() {
store.view = "error-logs";
store.section = "error-logs";
showView(el("monitoringErrorLogsView"));
startPoll(refreshErrorLogs);
void refreshErrorLogs();
}
// ——— Hardware health ———
function renderHardware() {
const listEl = el("hardwareGroupsList");
const emptyEl = el("hardwareListEmpty");
if (!listEl) return;
const groups = store.hardwareGroups;
listEl.innerHTML = "";
if (emptyEl) emptyEl.hidden = groups.length > 0;
if (!groups.length) return;
groups.forEach((group) => {
const gid = group.id || group.name;
const expanded = store.hardwareExpanded.has(gid);
const section = document.createElement("section");
section.className = "monHwGroup";
section.innerHTML = `
<button type="button" class="monHwGroupHeader" data-hw-toggle="${escapeHtml(gid)}" aria-expanded="${expanded}">
<span class="monHwGroupArrow">${expanded ? "▾" : "▸"}</span>
<span class="monHwStatusDot ${hwStatusClass(group.status)}"></span>
<span class="monHwGroupName">${escapeHtml(group.name || gid)}</span>
<span class="monHwGroupBadge ${hwStatusClass(group.status)}">${escapeHtml(group.status_label || group.status || "")}</span>
</button>
<div class="monHwGroupBody" ${expanded ? "" : "hidden"}></div>`;
const body = section.querySelector(".monHwGroupBody");
const components = Array.isArray(group.components) ? group.components : [];
components.forEach((c) => {
const row = document.createElement("div");
row.className = "monHwComponent";
row.innerHTML = `
<span class="monHwStatusDot ${hwStatusClass(c.status)}"></span>
<span class="monHwComponentName">${escapeHtml(c.name || c.id || "—")}</span>
<span class="monHwComponentMsg">${escapeHtml(c.message || c.status_label || "")}</span>`;
body?.appendChild(row);
});
listEl.appendChild(section);
});
}
async function refreshHardware() {
const data = await apiJson("/api/monitoring/hardware_health");
store.hardwareGroups = Array.isArray(data.groups) ? data.groups : [];
renderHardware();
}
function showHardware() {
store.view = "hardware-health";
store.section = "hardware-health";
showView(el("monitoringHardwareView"));
startPoll(refreshHardware);
void refreshHardware();
}
// ——— Safety ———
function renderSafety() {
const s = store.safety || {};
const estop = s.emergency_stop || "released";
const front = s.front_scanner || "free";
const rear = s.rear_scanner || "free";
const setCard = (statusEl, cardEl, value, okValues, okKey, badKey) => {
if (!statusEl || !cardEl) return;
const ok = okValues.includes(value);
statusEl.textContent = ok ? t(okKey) : t(badKey);
cardEl.classList.toggle("monSafetyCard--ok", ok);
cardEl.classList.toggle("monSafetyCard--bad", !ok);
};
setCard(el("safetyEstopStatus"), el("safetyEstopCard"), estop, ["released"], "monitoring.safety.estopReleased", "monitoring.safety.estopActivated");
setCard(el("safetyFrontStatus"), el("safetyFrontCard"), front, ["free"], "monitoring.safety.scannerFree", "monitoring.safety.scannerBlocked");
setCard(el("safetyRearStatus"), el("safetyRearCard"), rear, ["free"], "monitoring.safety.scannerFree", "monitoring.safety.scannerBlocked");
}
async function refreshSafety() {
store.safety = await apiJson("/api/monitoring/safety");
renderSafety();
}
function showSafety() {
store.view = "safety-system";
store.section = "safety-system";
showView(el("monitoringSafetyView"));
startPoll(refreshSafety);
void refreshSafety();
}
// ——— Mission log (Phase 1) ———
const missionLogViewEl = () => el("monitoringMissionLogView");
const actionLogViewEl = () => el("monitoringActionLogView");
const missionLogListEl = () => el("missionLogList");
const actionLogListEl = () => el("actionLogList");
function findEntry(id) {
return (
store.queue.find((e) => e && e.id === id) ||
store.runs.find((e) => e && e.id === id) ||
null
);
}
function missionLogEntries() {
const items = [...store.runs, ...store.queue];
const seen = new Set();
return items.filter((e) => {
if (!e || typeof e !== "object") return false;
if (seen.has(e.id)) return false;
seen.add(e.id);
const status = String(e.status || "");
return status === "executing" || status === "completed" || status === "failed" || status === "cancelled";
});
}
function formatDuration(ms) {
if (!Number.isFinite(ms) || ms < 0) return "—";
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
const rem = sec % 60;
if (min < 60) return `${min}m ${rem}s`;
const hr = Math.floor(min / 60);
return `${hr}h ${min % 60}m`;
}
function entryDuration(entry) {
const start = entry.started_at ? new Date(entry.started_at).getTime() : NaN;
if (!Number.isFinite(start)) return "—";
const end = entry.finished_at ? new Date(entry.finished_at).getTime() : Date.now();
return formatDuration(end - start);
}
function entryMessage(entry) {
if (entry.status === "executing") {
const cur = store.runner?.current_action;
if (cur) return String(cur);
return store.runner?.message || t("monitoring.missionLog.running");
}
const log = Array.isArray(entry.log) ? entry.log : [];
const last = log.length ? log[log.length - 1] : null;
if (last?.message) return String(last.message);
if (entry.status === "failed") return t("monitoring.missionLog.stateFailed");
if (entry.status === "cancelled") return t("monitoring.missionLog.stateCancelled");
if (entry.status === "completed") return t("monitoring.missionLog.stateCompleted");
return "—";
}
function formatStartedBy(entry) {
const src = String(entry.source || "ui");
const key = `monitoring.missionLog.source.${src}`;
const label = t(key);
return label === key ? src : label;
}
function stateLabel(status) {
const key = `monitoring.missionLog.state.${status}`;
const label = t(key);
return label === key ? status : label;
}
function stateClass(status) {
if (status === "executing") return "monMirState--running";
if (status === "completed") return "monMirState--ok";
if (status === "failed") return "monMirState--error";
if (status === "cancelled") return "monMirState--warn";
return "";
}
function levelLabel(level) {
const key = `monitoring.actionLog.level.${level || "info"}`;
const label = t(key);
return label === key ? level || "info" : label;
}
function levelClass(level) {
if (level === "error") return "monMirState--error";
if (level === "warn") return "monMirState--warn";
if (level === "user") return "monMirState--user";
return "monMirState--ok";
}
function filteredMissionEntries() {
const q = store.missionFilter.trim().toLowerCase();
let items = missionLogEntries();
if (q) {
items = items.filter((e) => {
const name = String(e.mission_name || "").toLowerCase();
const state = stateLabel(e.status).toLowerCase();
return name.includes(q) || state.includes(q) || String(e.status).includes(q);
});
}
return items.sort((a, b) => {
const ta = new Date(a.started_at || a.created_at || 0).getTime();
const tb = new Date(b.started_at || b.created_at || 0).getTime();
return tb - ta;
});
}
function renderMissionLog() {
const listEl = missionLogListEl();
if (!listEl) return;
const items = filteredMissionEntries();
const total = items.length;
const pages = pageCount(total);
if (store.missionPage > pages) store.missionPage = pages;
const start = (store.missionPage - 1) * PAGE_SIZE;
const pageItems = items.slice(start, start + PAGE_SIZE);
if (el("missionLogFilterCount")) el("missionLogFilterCount").textContent = t("monitoring.itemsFound", { n: total });
if (el("missionLogPageLabel")) el("missionLogPageLabel").textContent = t("monitoring.pageOf", { page: store.missionPage, total: pages });
listEl.innerHTML = "";
const tableEl = el("missionLogTable");
const emptyEl = el("missionLogListEmpty");
if (tableEl) tableEl.hidden = total === 0;
if (emptyEl) {
emptyEl.hidden = total > 0;
emptyEl.textContent = store.missionFilter ? t("monitoring.missionLog.emptyFilter") : t("monitoring.missionLog.empty");
}
pageItems.forEach((entry) => {
const status = entry.status || "—";
const isPersisted = entry.__persisted === true;
const tr = document.createElement("tr");
tr.className = "monMirRow";
tr.innerHTML = `
<td class="monMirTdIcon">${ICONS.mission}</td>
<td>${escapeHtml(entry.mission_name || entry.mission_id || "—")}</td>
<td><span class="monMirState ${stateClass(status)}">${escapeHtml(stateLabel(status))}</span></td>
<td class="monMirMessageCell">${escapeHtml(entryMessage(entry))}</td>
<td>${escapeHtml(formatTime(entry.started_at || entry.created_at))}</td>
<td>${escapeHtml(entryDuration(entry))}</td>
<td>${escapeHtml(formatStartedBy(entry))}</td>
<td class="mapsMirTdFunctions">
<button type="button" class="mapsMirIconBtn" data-view-log="${escapeHtml(entry.id)}" title="${escapeHtml(t("monitoring.missionLog.viewActions"))}">${ICONS.view}</button>
${
isPersisted && status !== "executing"
? `<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete-run="${escapeHtml(entry.id)}" title="${escapeHtml(t("monitoring.missionLog.deleteRun"))}">${ICONS.delete}</button>`
: ""
}
${
isPersisted && status !== "executing"
? `<a class="mapsMirIconBtn" href="/api/monitoring/mission_runs/${encodeURIComponent(entry.id)}/download" download title="${escapeHtml(t("monitoring.missionLog.downloadRun"))}">${ICONS.download}</a>`
: ""
}
</td>`;
listEl.appendChild(tr);
});
}
function buildActionRows(entry) {
const rows = [];
const cached = store.runActions.get(entry?.id);
const log = Array.isArray(entry?.log) ? entry.log : Array.isArray(cached) ? cached : [];
log.forEach((line, idx) => {
const next = log[idx + 1];
const ts = line.ts ? new Date(line.ts).getTime() : NaN;
const nextTs = next?.ts ? new Date(next.ts).getTime() : NaN;
const ranFor = Number.isFinite(ts) && Number.isFinite(nextTs) ? formatDuration(nextTs - ts) : "—";
rows.push({
action: line.action || line.message || "—",
state: line.level || "info",
message: line.message || "—",
start: line.ts,
ranFor,
current: false,
});
});
if (entry?.status === "executing" && store.runner?.current_queue_id === entry.id) {
const cur = store.runner.current_action;
if (cur) {
rows.unshift({
action: cur,
state: "executing",
message: store.runner.message || cur,
start: store.runner.updated_at || null,
ranFor: "—",
current: true,
});
}
}
return rows;
}
function filteredActionRows(entry) {
const q = store.actionFilter.trim().toLowerCase();
let rows = buildActionRows(entry);
if (q) {
rows = rows.filter((r) => {
return (
String(r.action).toLowerCase().includes(q) ||
String(r.message).toLowerCase().includes(q) ||
levelLabel(r.state).toLowerCase().includes(q)
);
});
}
return rows;
}
function renderActionLog() {
const listEl = actionLogListEl();
if (!listEl) return;
const entry = findEntry(store.selectedEntryId);
if (!entry) {
listEl.innerHTML = "";
if (el("actionLogListEmpty")) {
el("actionLogListEmpty").hidden = false;
el("actionLogListEmpty").textContent = t("monitoring.actionLog.missingEntry");
}
return;
}
const rows = filteredActionRows(entry);
const total = rows.length;
const pages = pageCount(total);
if (store.actionPage > pages) store.actionPage = pages;
const start = (store.actionPage - 1) * PAGE_SIZE;
const pageItems = rows.slice(start, start + PAGE_SIZE);
if (el("actionLogFilterCount")) el("actionLogFilterCount").textContent = t("monitoring.itemsFound", { n: total });
if (el("actionLogPageLabel")) el("actionLogPageLabel").textContent = t("monitoring.pageOf", { page: store.actionPage, total: pages });
listEl.innerHTML = "";
const tableEl = el("actionLogTable");
const emptyEl = el("actionLogListEmpty");
if (tableEl) tableEl.hidden = total === 0;
if (emptyEl) {
emptyEl.hidden = total > 0;
emptyEl.textContent = store.actionFilter ? t("monitoring.actionLog.emptyFilter") : t("monitoring.actionLog.empty");
}
pageItems.forEach((row) => {
const tr = document.createElement("tr");
tr.className = `monMirRow${row.current ? " monMirRow--current" : ""}`;
tr.innerHTML = `
<td class="monMirTdIcon">${ICONS.action}</td>
<td>${escapeHtml(row.action)}</td>
<td><span class="monMirState ${row.state === "executing" ? "monMirState--running" : levelClass(row.state)}">${escapeHtml(row.state === "executing" ? t("monitoring.actionLog.state.executing") : levelLabel(row.state))}</span></td>
<td class="monMirMessageCell">${escapeHtml(row.message)}</td>
<td>${escapeHtml(formatTime(row.start))}</td>
<td>${escapeHtml(row.ranFor)}</td>`;
listEl.appendChild(tr);
});
}
async function refreshMissionLog() {
const [queueData, runsData] = await Promise.all([
apiJson("/api/mission_queue"),
apiJson("/api/monitoring/mission_runs?limit=500"),
]);
store.queue = Array.isArray(queueData.queue) ? queueData.queue : [];
store.runner = queueData.runner && typeof queueData.runner === "object" ? queueData.runner : {};
store.runs = (Array.isArray(runsData.runs) ? runsData.runs : []).map((r) => ({ ...r, __persisted: true }));
if (store.view === "mission-log") renderMissionLog();
else if (store.view === "action-log") renderActionLog();
}
function showMissionLogList() {
store.view = "mission-log";
store.section = "mission-log";
store.selectedEntryId = null;
showView(missionLogViewEl());
startPoll(refreshMissionLog);
void refreshMissionLog();
}
function showActionLog(entryId) {
store.view = "action-log";
store.selectedEntryId = entryId;
store.actionFilter = "";
store.actionPage = 1;
if (el("actionLogFilterInput")) el("actionLogFilterInput").value = "";
const entry = findEntry(entryId);
if (el("actionLogSubtitle")) {
el("actionLogSubtitle").textContent = entry
? t("monitoring.actionLog.subtitleMission", { name: entry.mission_name || entry.mission_id || "—" })
: "";
}
const dl = el("actionLogDownloadBtn");
if (dl) {
const persisted = entry && entry.__persisted === true;
dl.hidden = !persisted;
if (persisted) dl.href = `/api/monitoring/mission_runs/${encodeURIComponent(entryId)}/download`;
else dl.href = "#";
}
showView(actionLogViewEl());
startPoll(refreshMissionLog);
if (entry && (!Array.isArray(entry.log) || entry.log.length === 0) && !store.runActions.has(entryId)) {
apiJson(`/api/monitoring/mission_runs/${encodeURIComponent(entryId)}/actions`)
.then((data) => {
const items = Array.isArray(data.items) ? data.items : [];
store.runActions.set(entryId, items);
renderActionLog();
})
.catch(() => {});
}
renderActionLog();
}
function showSection(section) {
store.section = section;
if (section === "analytics") showAnalytics();
else if (section === "monitoring-log") showSystemLog();
else if (section === "error-logs") showErrorLogs();
else if (section === "hardware-health") showHardware();
else if (section === "safety-system") showSafety();
else if (section === "mission-log") showMissionLogList();
else showAnalytics();
}
function bindPager(prefix, getTotal, getPage, setPage, render) {
const bind = (id, fn) => el(id)?.addEventListener("click", fn);
bind(`${prefix}PageFirst`, () => { setPage(1); render(); });
bind(`${prefix}PagePrev`, () => { setPage(Math.max(1, getPage() - 1)); render(); });
bind(`${prefix}PageNext`, () => { setPage(getPage() + 1); render(); });
bind(`${prefix}PageLast`, () => { setPage(pageCount(getTotal())); render(); });
}
function bindEvents() {
initAnalyticsPresets();
el("analyticsRefreshBtn")?.addEventListener("click", () => refreshAnalytics().catch((e) => alert(e.message)));
el("analyticsHelpBtn")?.addEventListener("click", () => alert(t("monitoring.analytics.helpBody")));
["analyticsStartDate", "analyticsEndDate", "analyticsGrouping", "analyticsChartMode"].forEach((id) => {
el(id)?.addEventListener("change", () => refreshAnalytics().catch(() => {}));
});
el("analyticsPresets")?.addEventListener("click", (evt) => {
const btn = evt.target.closest("[data-preset-days]");
if (!btn) return;
const days = Number(btn.dataset.presetDays);
const today = formatDateYmd(new Date());
setAnalyticsDateRange(addDaysYmd(today, days), today);
refreshAnalytics().catch(() => {});
});
el("systemLogFilterInput")?.addEventListener("input", () => {
store.systemLogFilter = el("systemLogFilterInput").value;
store.systemLogPage = 1;
renderSystemLog();
});
el("systemLogClearFiltersBtn")?.addEventListener("click", () => {
store.systemLogFilter = "";
store.systemLogPage = 1;
if (el("systemLogFilterInput")) el("systemLogFilterInput").value = "";
renderSystemLog();
});
el("systemLogRefreshBtn")?.addEventListener("click", () => refreshSystemLog().catch((e) => alert(e.message)));
bindPager("systemLog", () => filteredSystemLog().length, () => store.systemLogPage, (p) => { store.systemLogPage = p; }, renderSystemLog);
el("errorLogsFilterInput")?.addEventListener("input", () => {
store.errorLogFilter = el("errorLogsFilterInput").value;
renderErrorLogs();
});
el("errorLogsRefreshBtn")?.addEventListener("click", () => refreshErrorLogs().catch((e) => alert(e.message)));
el("errorLogsGenerateBtn")?.addEventListener("click", () => {
apiJson("/api/monitoring/error_logs/generate", { method: "POST" })
.then(() => refreshErrorLogs())
.catch((e) => alert(e.message));
});
el("errorLogsDeleteAllBtn")?.addEventListener("click", () => {
if (!window.confirm(t("monitoring.errorLogs.deleteAllConfirm"))) return;
apiJson("/api/monitoring/error_logs", { method: "DELETE" })
.then(() => refreshErrorLogs())
.catch((e) => alert(e.message));
});
el("errorLogsList")?.addEventListener("click", (evt) => {
const btn = evt.target.closest("[data-delete-error]");
if (!btn?.dataset.deleteError) return;
apiJson(`/api/monitoring/error_logs/${encodeURIComponent(btn.dataset.deleteError)}`, { method: "DELETE" })
.then(() => refreshErrorLogs())
.catch((e) => alert(e.message));
});
el("hardwareRefreshBtn")?.addEventListener("click", () => refreshHardware().catch((e) => alert(e.message)));
el("hardwareGroupsList")?.addEventListener("click", (evt) => {
const btn = evt.target.closest("[data-hw-toggle]");
if (!btn?.dataset.hwToggle) return;
const gid = btn.dataset.hwToggle;
if (store.hardwareExpanded.has(gid)) store.hardwareExpanded.delete(gid);
else store.hardwareExpanded.add(gid);
renderHardware();
});
el("missionLogFilterInput")?.addEventListener("input", () => {
store.missionFilter = el("missionLogFilterInput").value;
store.missionPage = 1;
renderMissionLog();
});
el("missionLogClearFiltersBtn")?.addEventListener("click", () => {
store.missionFilter = "";
store.missionPage = 1;
if (el("missionLogFilterInput")) el("missionLogFilterInput").value = "";
renderMissionLog();
});
el("missionLogRefreshBtn")?.addEventListener("click", () => refreshMissionLog().catch((e) => alert(e.message)));
el("missionLogClearHistoryBtn")?.addEventListener("click", () => {
if (!window.confirm(t("monitoring.missionLog.clearHistoryConfirm"))) return;
apiJson("/api/monitoring/mission_runs", { method: "DELETE" })
.then(() => refreshMissionLog())
.catch((e) => alert(e.message));
});
el("missionLogHelpBtn")?.addEventListener("click", () => alert(t("monitoring.missionLog.helpBody")));
missionLogListEl()?.addEventListener("click", (evt) => {
const btn = evt.target.closest("[data-view-log]");
if (!btn?.dataset.viewLog) return;
showActionLog(btn.dataset.viewLog);
});
missionLogListEl()?.addEventListener("click", (evt) => {
const btn = evt.target.closest("[data-delete-run]");
if (!btn?.dataset.deleteRun) return;
if (!window.confirm(t("monitoring.missionLog.deleteRunConfirm"))) return;
apiJson(`/api/monitoring/mission_runs/${encodeURIComponent(btn.dataset.deleteRun)}`, { method: "DELETE" })
.then(() => refreshMissionLog())
.catch((e) => alert(e.message));
});
el("actionLogBackBtn")?.addEventListener("click", () => showMissionLogList());
el("actionLogFilterInput")?.addEventListener("input", () => {
store.actionFilter = el("actionLogFilterInput").value;
store.actionPage = 1;
renderActionLog();
});
el("actionLogClearFiltersBtn")?.addEventListener("click", () => {
store.actionFilter = "";
store.actionPage = 1;
if (el("actionLogFilterInput")) el("actionLogFilterInput").value = "";
renderActionLog();
});
bindPager("missionLog", () => filteredMissionEntries().length, () => store.missionPage, (p) => { store.missionPage = p; }, renderMissionLog);
bindPager("actionLog", () => {
const entry = findEntry(store.selectedEntryId);
return entry ? filteredActionRows(entry).length : 0;
}, () => store.actionPage, (p) => { store.actionPage = p; }, renderActionLog);
window.addEventListener("lm:locale-change", () => {
if (store.view === "analytics") renderAnalyticsChart();
else if (store.view === "system-log") renderSystemLog();
else if (store.view === "error-logs") renderErrorLogs();
else if (store.view === "hardware-health") renderHardware();
else if (store.view === "safety-system") renderSafety();
else if (store.view === "mission-log") renderMissionLog();
else if (store.view === "action-log") renderActionLog();
});
}
function onPageShow() {
const section = window.NavApp?.getActiveSection?.() || store.section || "analytics";
showSection(section);
}
function onPageHide() {
stopPoll();
}
window.MonitoringApp = { init: bindEvents, onPageShow, onPageHide, showSection, refresh: refreshMissionLog };
function boot() {
bindEvents();
}
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
})();