928 lines
36 KiB
JavaScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
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 });
|
|
})();
|