Thêm phần 5. Monitoring và 6. System
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-25 16:00:18 +07:00
parent e0e864ea13
commit 77ea3cc361
27 changed files with 3564 additions and 31 deletions

View File

@@ -10,6 +10,7 @@ const pageConfigEl = el("pageConfig");
const pageMapsEl = el("pageMaps");
const pageMissionsEl = el("pageMissions");
const pageIntegrationsEl = el("pageIntegrations");
const pageSettingsEl = el("pageSettings");
const pageSoundsEl = el("pageSounds");
const pageTransitionsEl = el("pageTransitions");
const pageUsersEl = el("pageUsers");
@@ -131,7 +132,7 @@ const state = {
};
function setActivePage(page) {
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "path-guides", "users", "integrations", "monitoring", "help"];
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "path-guides", "users", "settings", "integrations", "monitoring", "help"];
let p = valid.includes(page) ? page : "missions";
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
@@ -149,6 +150,7 @@ function setActivePage(page) {
if (pagePathsEl) pagePathsEl.hidden = p !== "paths";
if (pagePathGuidesEl) pagePathGuidesEl.hidden = p !== "path-guides";
if (pageUsersEl) pageUsersEl.hidden = p !== "users";
if (pageSettingsEl) pageSettingsEl.hidden = p !== "settings";
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
@@ -166,6 +168,7 @@ function setActivePage(page) {
contentEl.classList.toggle("content--paths", p === "paths");
contentEl.classList.toggle("content--path-guides", p === "path-guides");
contentEl.classList.toggle("content--users", p === "users");
contentEl.classList.toggle("content--settings", p === "settings");
contentEl.classList.toggle("content--integrations", p === "integrations");
contentEl.classList.toggle("content--monitoring", p === "monitoring");
contentEl.classList.toggle("content--help", p === "help");
@@ -191,6 +194,10 @@ function setActivePage(page) {
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
else if (window.IntegrationsApp?.onPageHide) window.IntegrationsApp.onPageHide();
if (p === "settings" && window.SettingsApp) window.SettingsApp.onPageShow();
else if (window.SettingsApp?.onPageHide) window.SettingsApp.onPageHide();
if (p === "monitoring" && window.MonitoringApp) window.MonitoringApp.onPageShow();
else if (window.MonitoringApp?.onPageHide) window.MonitoringApp.onPageHide();
window.NavApp?.syncFromPage?.(p);
try {
localStorage.setItem("activePage", p);

View File

@@ -66,6 +66,7 @@
"nav.setup": "Setup",
"nav.monitoring": "Monitoring",
"nav.system": "System",
"nav.settings": "Settings",
"nav.help": "Help",
"nav.logout": "Log out",
"nav.dashboard": "Dashboard",
@@ -81,6 +82,11 @@
"nav.users": "Users",
"nav.build-robot": "Build Robot",
"nav.monitoring-log": "System log",
"nav.analytics": "Analytics",
"nav.error-logs": "Error logs",
"nav.hardware-health": "Hardware health",
"nav.safety-system": "Safety system",
"nav.mission-log": "Mission log",
"nav.integrations": "Tích hợp",
"nav.help-api": "API documentation",
@@ -884,9 +890,150 @@
"integrations.dialog.schedule.startTime": "Thời gian bắt đầu",
"integrations.schedule.runNow": "Chạy ngay",
"monitoring.log.title": "System log",
"monitoring.log.subtitle": "Monitoring → System log — nhật ký hệ thống (đang phát triển).",
"monitoring.log.placeholder": "Tính năng monitoring sẽ hiển thị log robot, cảnh báo và lịch sử mission tại đây.",
"monitoring.comingSoon": "Tính năng đang phát triển.",
"monitoring.refresh": "Làm mới",
"monitoring.clearFilters": "Xóa bộ lọc",
"monitoring.filterLabel": "Lọc:",
"monitoring.itemsFound": "{n} mục",
"monitoring.pageOf": "Trang {page} / {total}",
"monitoring.analytics.title": "Analytics",
"monitoring.analytics.subtitle": "Quãng đường robot đã chạy theo thời gian.",
"monitoring.analytics.helpTitle": "Trợ giúp Analytics",
"monitoring.analytics.helpBody": "Biểu đồ quãng đường (mét) theo ngày hoặc tháng. Chọn khoảng thời gian hoặc dùng nút preset. Dữ liệu tích lũy khi robot di chuyển (joystick/mission).",
"monitoring.analytics.startDate": "Start date",
"monitoring.analytics.endDate": "End date",
"monitoring.analytics.grouping": "Grouping",
"monitoring.analytics.perDay": "Per day",
"monitoring.analytics.perMonth": "Per month",
"monitoring.analytics.chartType": "Chart",
"monitoring.analytics.barChart": "Bar graph",
"monitoring.analytics.accumulated": "Accumulated",
"monitoring.analytics.presetWeek": "Current week",
"monitoring.analytics.preset7d": "Last 7 days",
"monitoring.analytics.preset30d": "Last 30 days",
"monitoring.analytics.preset365d": "Last 365 days",
"monitoring.analytics.totalPeriod": "Kỳ đã chọn: {n} m · Tổng tích lũy: {lifetime} m",
"monitoring.analytics.empty": "Không có dữ liệu quãng đường trong kỳ này.",
"monitoring.systemLog.title": "System log",
"monitoring.systemLog.subtitle": "Sự kiện từ các thành phần hệ thống.",
"monitoring.systemLog.filterPlaceholder": "Lọc theo module, message hoặc state...",
"monitoring.systemLog.colState": "State",
"monitoring.systemLog.colModule": "Module",
"monitoring.systemLog.colMessage": "Message",
"monitoring.systemLog.colTime": "Time",
"monitoring.systemLog.empty": "Chưa có sự kiện system log.",
"monitoring.systemLog.emptyFilter": "Không có sự kiện khớp bộ lọc.",
"monitoring.errorLogs.title": "Error logs",
"monitoring.errorLogs.subtitle": "Danh sách lỗi hệ thống đã phát hiện.",
"monitoring.errorLogs.filterPlaceholder": "Lọc theo mô tả hoặc module...",
"monitoring.errorLogs.colDescription": "Description",
"monitoring.errorLogs.colModule": "Module",
"monitoring.errorLogs.colTime": "Time",
"monitoring.errorLogs.colFunctions": "Functions",
"monitoring.errorLogs.generate": "Generate log",
"monitoring.errorLogs.deleteAll": "Delete all",
"monitoring.errorLogs.deleteAllConfirm": "Xóa toàn bộ error log?",
"monitoring.errorLogs.download": "Download",
"monitoring.errorLogs.delete": "Delete",
"monitoring.errorLogs.empty": "Chưa có lỗi được ghi.",
"monitoring.errorLogs.emptyFilter": "Không có lỗi khớp bộ lọc.",
"monitoring.hardware.title": "Hardware health",
"monitoring.hardware.subtitle": "Tình trạng phần cứng robot.",
"monitoring.hardware.empty": "Không có dữ liệu phần cứng.",
"monitoring.safety.title": "Safety system",
"monitoring.safety.subtitle": "Trạng thái E-stop và laser scanner (live).",
"monitoring.safety.estop": "Emergency stop",
"monitoring.safety.frontScanner": "Front scanner",
"monitoring.safety.rearScanner": "Rear scanner",
"monitoring.safety.estopReleased": "Released",
"monitoring.safety.estopActivated": "Activated",
"monitoring.safety.scannerFree": "Free",
"monitoring.safety.scannerBlocked": "Blocked",
"monitoring.missionLog.title": "Mission log",
"monitoring.missionLog.subtitle": "Mission đã chạy và đang chạy.",
"monitoring.missionLog.helpTitle": "Trợ giúp Mission log",
"monitoring.missionLog.helpBody": "Danh sách mission đã chạy/đang chạy. Bấm biểu tượng mắt để xem action log chi tiết. Phase 3: lịch sử được lưu bền vững (không mất khi xóa queue).",
"monitoring.missionLog.filterPlaceholder": "Lọc theo tên mission hoặc state...",
"monitoring.missionLog.colMission": "Mission",
"monitoring.missionLog.colState": "State",
"monitoring.missionLog.colMessage": "Message",
"monitoring.missionLog.colStart": "Start time",
"monitoring.missionLog.colRanFor": "Ran for",
"monitoring.missionLog.colStartedBy": "Started by",
"monitoring.missionLog.colFunctions": "Functions",
"monitoring.missionLog.empty": "Chưa có mission trong log.",
"monitoring.missionLog.emptyFilter": "Không có mission khớp bộ lọc.",
"monitoring.missionLog.viewActions": "Xem action log",
"monitoring.missionLog.clearHistory": "Xóa lịch sử",
"monitoring.missionLog.clearHistoryConfirm": "Xóa toàn bộ lịch sử mission log?",
"monitoring.missionLog.deleteRun": "Xóa",
"monitoring.missionLog.deleteRunConfirm": "Xóa mission này khỏi lịch sử?",
"monitoring.missionLog.downloadRun": "Tải log",
"monitoring.missionLog.running": "Đang chạy…",
"monitoring.missionLog.stateCompleted": "Hoàn thành",
"monitoring.missionLog.stateFailed": "Thất bại",
"monitoring.missionLog.stateCancelled": "Đã hủy",
"monitoring.missionLog.state.executing": "Executing",
"monitoring.missionLog.state.completed": "Completed",
"monitoring.missionLog.state.failed": "Failed",
"monitoring.missionLog.state.cancelled": "Cancelled",
"monitoring.missionLog.source.ui": "User interface",
"monitoring.missionLog.source.modbus": "Modbus",
"monitoring.missionLog.source.api": "REST API",
"monitoring.missionLog.source.schedule": "Scheduler",
"monitoring.actionLog.title": "Mission action log",
"monitoring.actionLog.back": "← Mission log",
"monitoring.actionLog.subtitleMission": "Mission: {name}",
"monitoring.actionLog.filterPlaceholder": "Lọc theo action, state hoặc message...",
"monitoring.actionLog.colAction": "Action",
"monitoring.actionLog.colState": "State",
"monitoring.actionLog.colMessage": "Message",
"monitoring.actionLog.colStart": "Start time",
"monitoring.actionLog.colRanFor": "Ran for",
"monitoring.actionLog.empty": "Chưa có action nào trong log.",
"monitoring.actionLog.emptyFilter": "Không có action khớp bộ lọc.",
"monitoring.actionLog.missingEntry": "Không tìm thấy mission.",
"monitoring.actionLog.state.executing": "Executing",
"monitoring.actionLog.level.info": "Info",
"monitoring.actionLog.level.warn": "Warning",
"monitoring.actionLog.level.error": "Error",
"monitoring.actionLog.level.user": "User",
"monitoring.actionLog.download": "Download",
"settings.title": "Settings",
"settings.subtitle": "System → Settings — cấu hình hệ thống robot.",
"settings.loading": "Đang tải settings…",
"settings.loaded": "Đã tải.",
"settings.apply": "Apply",
"settings.back": "Quay lại",
"settings.saving": "Đang lưu…",
"settings.saved": "Đã lưu.",
"settings.saveFailed": "Lưu thất bại.",
"settings.loggingRetention.title": "Logging & retention",
"settings.loggingRetention.subtitle": "Giới hạn lịch sử và mức độ log.",
"settings.retention.missionRuns": "Mission history (runs)",
"settings.retention.missionRunsHint": "Số lượng mission runs được lưu (lịch sử có giới hạn).",
"settings.retention.systemLog": "System log entries",
"settings.retention.systemLogHint": "Số dòng system log tối đa lưu trong DB.",
"settings.retention.errorLogs": "Error log entries",
"settings.retention.errorLogsHint": "Số dòng error log tối đa lưu trong DB.",
"settings.logLevel": "Log level",
"settings.logLevelHint": "Điều khiển mức độ chi tiết của system log.",
"settings.comingSoon.title": "More settings",
"settings.comingSoon.subtitle": "Time, network, security và backups sẽ nằm ở đây.",
"settings.tile.mapping": "Mapping",
"settings.tile.mappingSub": "Chỉnh cấu hình mapping.",
"settings.tile.errorHandling": "Error handling",
"settings.tile.errorHandlingSub": "Chỉnh xử lý lỗi của robot.",
"settings.tile.battery": "Battery",
"settings.tile.batterySub": "Cấu hình cho battery.",
"settings.tile.wifi": "WiFi",
"settings.tile.wifiSub": "Cấu hình kết nối WiFi.",
"settings.tile.dateTime": "Date & time",
"settings.tile.dateTimeSub": "Thiết lập thời gian hệ thống.",
"settings.tile.advanced": "Advanced",
"settings.tile.advancedSub": "Retention và logging.",
"help.api.title": "API documentation",
"help.api.subtitle": "Help → API — tham chiếu REST MiR v2.0.0 cho tích hợp bên ngoài.",
@@ -954,6 +1101,7 @@
"nav.setup": "Setup",
"nav.monitoring": "Monitoring",
"nav.system": "System",
"nav.settings": "Settings",
"nav.help": "Help",
"nav.logout": "Log out",
"nav.dashboard": "Dashboard",
@@ -969,6 +1117,11 @@
"nav.users": "Users",
"nav.build-robot": "Build Robot",
"nav.monitoring-log": "System log",
"nav.analytics": "Analytics",
"nav.error-logs": "Error logs",
"nav.hardware-health": "Hardware health",
"nav.safety-system": "Safety system",
"nav.mission-log": "Mission log",
"nav.integrations": "Integrations",
"nav.help-api": "API documentation",
@@ -1772,9 +1925,150 @@
"integrations.dialog.schedule.startTime": "Start time",
"integrations.schedule.runNow": "Run now",
"monitoring.log.title": "System log",
"monitoring.log.subtitle": "Monitoring → System log — system log (coming soon).",
"monitoring.log.placeholder": "Monitoring will show robot logs, alerts and mission history here.",
"monitoring.comingSoon": "Feature coming soon.",
"monitoring.refresh": "Refresh",
"monitoring.clearFilters": "Clear filters",
"monitoring.filterLabel": "Filter:",
"monitoring.itemsFound": "{n} item(s) found",
"monitoring.pageOf": "Page {page} of {total}",
"monitoring.analytics.title": "Analytics",
"monitoring.analytics.subtitle": "Driven distance over time.",
"monitoring.analytics.helpTitle": "Analytics help",
"monitoring.analytics.helpBody": "Chart of driven distance (meters) per day or month. Select a date range or use a preset. Distance accumulates when the robot moves (joystick/missions).",
"monitoring.analytics.startDate": "Start date",
"monitoring.analytics.endDate": "End date",
"monitoring.analytics.grouping": "Grouping",
"monitoring.analytics.perDay": "Per day",
"monitoring.analytics.perMonth": "Per month",
"monitoring.analytics.chartType": "Chart",
"monitoring.analytics.barChart": "Bar graph",
"monitoring.analytics.accumulated": "Accumulated",
"monitoring.analytics.presetWeek": "Current week",
"monitoring.analytics.preset7d": "Last 7 days",
"monitoring.analytics.preset30d": "Last 30 days",
"monitoring.analytics.preset365d": "Last 365 days",
"monitoring.analytics.totalPeriod": "Selected period: {n} m · Lifetime: {lifetime} m",
"monitoring.analytics.empty": "No distance data for this period.",
"monitoring.systemLog.title": "System log",
"monitoring.systemLog.subtitle": "Events from operating system components.",
"monitoring.systemLog.filterPlaceholder": "Filter by module, message or state...",
"monitoring.systemLog.colState": "State",
"monitoring.systemLog.colModule": "Module",
"monitoring.systemLog.colMessage": "Message",
"monitoring.systemLog.colTime": "Time",
"monitoring.systemLog.empty": "No system log entries yet.",
"monitoring.systemLog.emptyFilter": "No entries match the filter.",
"monitoring.errorLogs.title": "Error logs",
"monitoring.errorLogs.subtitle": "Detected system errors.",
"monitoring.errorLogs.filterPlaceholder": "Filter by description or module...",
"monitoring.errorLogs.colDescription": "Description",
"monitoring.errorLogs.colModule": "Module",
"monitoring.errorLogs.colTime": "Time",
"monitoring.errorLogs.colFunctions": "Functions",
"monitoring.errorLogs.generate": "Generate log",
"monitoring.errorLogs.deleteAll": "Delete all",
"monitoring.errorLogs.deleteAllConfirm": "Delete all error log entries?",
"monitoring.errorLogs.download": "Download",
"monitoring.errorLogs.delete": "Delete",
"monitoring.errorLogs.empty": "No errors logged.",
"monitoring.errorLogs.emptyFilter": "No errors match the filter.",
"monitoring.hardware.title": "Hardware health",
"monitoring.hardware.subtitle": "Robot hardware component status.",
"monitoring.hardware.empty": "No hardware data.",
"monitoring.safety.title": "Safety system",
"monitoring.safety.subtitle": "Live emergency stop and laser scanner status.",
"monitoring.safety.estop": "Emergency stop",
"monitoring.safety.frontScanner": "Front scanner",
"monitoring.safety.rearScanner": "Rear scanner",
"monitoring.safety.estopReleased": "Released",
"monitoring.safety.estopActivated": "Activated",
"monitoring.safety.scannerFree": "Free",
"monitoring.safety.scannerBlocked": "Blocked",
"monitoring.missionLog.title": "Mission log",
"monitoring.missionLog.subtitle": "Executed and currently running missions.",
"monitoring.missionLog.helpTitle": "Mission log help",
"monitoring.missionLog.helpBody": "Lists missions that have run or are running. Click the eye icon for the action log. Phase 3: history is persisted (clearing the queue does not delete history).",
"monitoring.missionLog.filterPlaceholder": "Filter by mission name or state...",
"monitoring.missionLog.colMission": "Mission",
"monitoring.missionLog.colState": "State",
"monitoring.missionLog.colMessage": "Message",
"monitoring.missionLog.colStart": "Start time",
"monitoring.missionLog.colRanFor": "Ran for",
"monitoring.missionLog.colStartedBy": "Started by",
"monitoring.missionLog.colFunctions": "Functions",
"monitoring.missionLog.empty": "No missions in the log yet.",
"monitoring.missionLog.emptyFilter": "No missions match the filter.",
"monitoring.missionLog.viewActions": "View action log",
"monitoring.missionLog.clearHistory": "Clear history",
"monitoring.missionLog.clearHistoryConfirm": "Clear all mission log history?",
"monitoring.missionLog.deleteRun": "Delete",
"monitoring.missionLog.deleteRunConfirm": "Delete this mission from history?",
"monitoring.missionLog.downloadRun": "Download log",
"monitoring.missionLog.running": "Running…",
"monitoring.missionLog.stateCompleted": "Completed",
"monitoring.missionLog.stateFailed": "Failed",
"monitoring.missionLog.stateCancelled": "Cancelled",
"monitoring.missionLog.state.executing": "Executing",
"monitoring.missionLog.state.completed": "Completed",
"monitoring.missionLog.state.failed": "Failed",
"monitoring.missionLog.state.cancelled": "Cancelled",
"monitoring.missionLog.source.ui": "User interface",
"monitoring.missionLog.source.modbus": "Modbus",
"monitoring.missionLog.source.api": "REST API",
"monitoring.missionLog.source.schedule": "Scheduler",
"monitoring.actionLog.title": "Mission action log",
"monitoring.actionLog.back": "← Mission log",
"monitoring.actionLog.subtitleMission": "Mission: {name}",
"monitoring.actionLog.filterPlaceholder": "Filter by action, state or message...",
"monitoring.actionLog.colAction": "Action",
"monitoring.actionLog.colState": "State",
"monitoring.actionLog.colMessage": "Message",
"monitoring.actionLog.colStart": "Start time",
"monitoring.actionLog.colRanFor": "Ran for",
"monitoring.actionLog.empty": "No actions logged for this mission.",
"monitoring.actionLog.emptyFilter": "No actions match the filter.",
"monitoring.actionLog.missingEntry": "Mission not found.",
"monitoring.actionLog.state.executing": "Executing",
"monitoring.actionLog.level.info": "Info",
"monitoring.actionLog.level.warn": "Warning",
"monitoring.actionLog.level.error": "Error",
"monitoring.actionLog.level.user": "User",
"monitoring.actionLog.download": "Download",
"settings.title": "Settings",
"settings.subtitle": "System → Settings — global robot configuration.",
"settings.loading": "Loading settings…",
"settings.loaded": "Loaded.",
"settings.apply": "Apply",
"settings.back": "Back",
"settings.saving": "Saving…",
"settings.saved": "Saved.",
"settings.saveFailed": "Save failed.",
"settings.loggingRetention.title": "Logging & retention",
"settings.loggingRetention.subtitle": "Tune history limits and log level.",
"settings.retention.missionRuns": "Mission history (runs)",
"settings.retention.missionRunsHint": "Max persisted mission runs (bounded history).",
"settings.retention.systemLog": "System log entries",
"settings.retention.systemLogHint": "Max number of system log rows stored.",
"settings.retention.errorLogs": "Error log entries",
"settings.retention.errorLogsHint": "Max number of error log rows stored.",
"settings.logLevel": "Log level",
"settings.logLevelHint": "Controls verbosity of system logs.",
"settings.comingSoon.title": "More settings",
"settings.comingSoon.subtitle": "Time, network, security and backups will appear here.",
"settings.tile.mapping": "Mapping",
"settings.tile.mappingSub": "Mapping configuration.",
"settings.tile.errorHandling": "Error handling",
"settings.tile.errorHandlingSub": "Robot error handling configuration.",
"settings.tile.battery": "Battery",
"settings.tile.batterySub": "Battery configuration.",
"settings.tile.wifi": "WiFi",
"settings.tile.wifiSub": "WiFi connection configuration.",
"settings.tile.dateTime": "Date & time",
"settings.tile.dateTimeSub": "Set system time.",
"settings.tile.advanced": "Advanced",
"settings.tile.advancedSub": "Retention and logging.",
"help.api.title": "API documentation",
"help.api.subtitle": "Help → API — MiR v2.0.0 REST reference for external integration.",

View File

@@ -2378,18 +2378,367 @@ GET /api/v2.0.0/status</pre>
</div>
</div>
<div class="page" id="pageSettings" data-page-content="settings" hidden>
<div class="mapsMirPage">
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="settings.title">Settings</h1>
<p class="mapsMirSubtitle" data-i18n="settings.subtitle">System → Settings — global robot configuration.</p>
</div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="settingsRefreshBtn">
<span data-i18n="monitoring.refresh">Refresh</span>
</button>
</div>
</header>
<section class="settingsMirGridWrap" id="settingsGridView">
<div class="settingsMirGrid" id="settingsTileGrid"></div>
</section>
<section class="mapsMirCard" id="settingsDetailView" hidden>
<header class="mapsMirCardHeader">
<div>
<h2 class="mapsMirCardTitle" data-i18n="settings.loggingRetention.title">Logging & retention</h2>
<p class="mapsMirCardSub" data-i18n="settings.loggingRetention.subtitle">Tune history limits and log level.</p>
</div>
<div class="mapsMirCardActions">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="settingsBackBtn">
<span data-i18n="settings.back">Back</span>
</button>
<button type="button" class="mapsMirBtn mapsMirBtn--primary" id="settingsApplyBtn">
<span data-i18n="settings.apply">Apply</span>
</button>
</div>
</header>
<div class="mapsMirCardBody">
<div class="mapsMirFormGrid">
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="settings.retention.missionRuns">Mission history (runs)</span>
<input type="number" min="10" max="20000" step="10" id="settingsRetentionMissionRuns" class="mapsMirInput" />
<span class="mapsMirFieldHint" data-i18n="settings.retention.missionRunsHint">Max persisted mission runs (bounded history).</span>
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="settings.retention.systemLog">System log entries</span>
<input type="number" min="100" max="20000" step="100" id="settingsRetentionSystemLog" class="mapsMirInput" />
<span class="mapsMirFieldHint" data-i18n="settings.retention.systemLogHint">Max number of system log rows stored.</span>
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="settings.retention.errorLogs">Error log entries</span>
<input type="number" min="10" max="5000" step="10" id="settingsRetentionErrorLogs" class="mapsMirInput" />
<span class="mapsMirFieldHint" data-i18n="settings.retention.errorLogsHint">Max number of error log rows stored.</span>
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="settings.logLevel">Log level</span>
<select id="settingsLogLevel" class="mapsMirSelect">
<option value="error">error</option>
<option value="warn">warn</option>
<option value="info">info</option>
<option value="debug">debug</option>
</select>
<span class="mapsMirFieldHint" data-i18n="settings.logLevelHint">Controls verbosity of system logs.</span>
</label>
</div>
<div class="mapsMirNote" id="settingsStatusText" aria-live="polite"></div>
</div>
</section>
<section class="mapsMirCard">
<header class="mapsMirCardHeader">
<div>
<h2 class="mapsMirCardTitle" data-i18n="settings.comingSoon.title">More settings</h2>
<p class="mapsMirCardSub" data-i18n="settings.comingSoon.subtitle">Time, network, security and backups will appear here.</p>
</div>
</header>
<div class="mapsMirCardBody">
<p class="mutedNote" data-i18n="monitoring.comingSoon">Feature coming soon.</p>
</div>
</section>
</div>
</div>
<div class="page" id="pageMonitoring" data-page-content="monitoring" hidden>
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle" data-i18n="monitoring.log.title">System log</div>
<div class="cardSub" data-i18n="monitoring.log.subtitle">Monitoring → System log — nhật ký hệ thống (đang phát triển).</div>
<div id="monitoringAnalyticsView" class="mapsMirPage" hidden>
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="monitoring.analytics.title">Analytics</h1>
<p class="mapsMirSubtitle">
<span data-i18n="monitoring.analytics.subtitle">Driven distance over time.</span>
<button type="button" class="mapsMirHelpBtn" id="analyticsHelpBtn" data-i18n-title="monitoring.analytics.helpTitle" aria-label="Help">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
</button>
</p>
</div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="analyticsRefreshBtn">
<span data-i18n="monitoring.refresh">Refresh</span>
</button>
</div>
</header>
<div class="monAnalyticsToolbar">
<div class="monAnalyticsDates">
<label class="monAnalyticsLabel" for="analyticsStartDate" data-i18n="monitoring.analytics.startDate">Start date</label>
<input type="date" id="analyticsStartDate" class="monAnalyticsInput" />
<label class="monAnalyticsLabel" for="analyticsEndDate" data-i18n="monitoring.analytics.endDate">End date</label>
<input type="date" id="analyticsEndDate" class="monAnalyticsInput" />
</div>
<div class="monAnalyticsPresets" id="analyticsPresets"></div>
<div class="monAnalyticsOptions">
<label class="monAnalyticsLabel" for="analyticsGrouping" data-i18n="monitoring.analytics.grouping">Grouping</label>
<select id="analyticsGrouping" class="monAnalyticsSelect">
<option value="day" data-i18n="monitoring.analytics.perDay">Per day</option>
<option value="month" data-i18n="monitoring.analytics.perMonth">Per month</option>
</select>
<label class="monAnalyticsLabel" for="analyticsChartMode" data-i18n="monitoring.analytics.chartType">Chart</label>
<select id="analyticsChartMode" class="monAnalyticsSelect">
<option value="bar" data-i18n="monitoring.analytics.barChart">Bar graph</option>
<option value="accumulated" data-i18n="monitoring.analytics.accumulated">Accumulated</option>
</select>
</div>
</div>
<div class="cardBody">
<p class="mutedNote" data-i18n="monitoring.log.placeholder">Tính năng monitoring sẽ hiển thị log robot, cảnh báo và lịch sử mission tại đây.</p>
<div class="monAnalyticsSummary">
<span id="analyticsTotalLabel" class="monAnalyticsTotal"></span>
</div>
</section>
<div class="monAnalyticsChartWrap">
<svg id="analyticsChart" class="monAnalyticsChart" viewBox="0 0 800 280" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Driven distance chart"></svg>
<div id="analyticsChartEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.analytics.empty">No distance data for this period.</div>
</div>
</div>
<div id="monitoringSystemLogView" class="mapsMirPage" hidden>
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="monitoring.systemLog.title">System log</h1>
<p class="mapsMirSubtitle" data-i18n="monitoring.systemLog.subtitle">Events from operating system components.</p>
</div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="systemLogRefreshBtn"><span data-i18n="monitoring.refresh">Refresh</span></button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="systemLogClearFiltersBtn"><span data-i18n="monitoring.clearFilters">Clear filters</span></button>
</div>
</header>
<div class="mapsMirFilterBar">
<label class="mapsMirFilterLabel" for="systemLogFilterInput" data-i18n="monitoring.filterLabel">Filter:</label>
<input type="search" id="systemLogFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="monitoring.systemLog.filterPlaceholder" autocomplete="off" />
<span id="systemLogFilterCount" class="mapsMirFilterCount">0</span>
<div class="mapsMirPager">
<button type="button" class="mapsMirPageBtn" id="systemLogPageFirst">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="systemLogPagePrev">&lsaquo;</button>
<span id="systemLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="systemLogPageNext">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="systemLogPageLast">&raquo;</button>
</div>
</div>
<div class="mapsMirTableWrap">
<table class="mapsMirTable mapsMirTable--systemLog" id="systemLogTable">
<thead>
<tr>
<th class="monMirThState" data-i18n="monitoring.systemLog.colState">State</th>
<th data-i18n="monitoring.systemLog.colModule">Module</th>
<th data-i18n="monitoring.systemLog.colMessage">Message</th>
<th data-i18n="monitoring.systemLog.colTime">Time</th>
</tr>
</thead>
<tbody id="systemLogList"></tbody>
</table>
<div id="systemLogListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.systemLog.empty">No system log entries.</div>
</div>
</div>
<div id="monitoringErrorLogsView" class="mapsMirPage" hidden>
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="monitoring.errorLogs.title">Error logs</h1>
<p class="mapsMirSubtitle" data-i18n="monitoring.errorLogs.subtitle">Detected system errors.</p>
</div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--primary" id="errorLogsGenerateBtn"><span data-i18n="monitoring.errorLogs.generate">Generate log</span></button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="errorLogsDeleteAllBtn"><span data-i18n="monitoring.errorLogs.deleteAll">Delete all</span></button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="errorLogsRefreshBtn"><span data-i18n="monitoring.refresh">Refresh</span></button>
</div>
</header>
<div class="mapsMirFilterBar">
<label class="mapsMirFilterLabel" for="errorLogsFilterInput" data-i18n="monitoring.filterLabel">Filter:</label>
<input type="search" id="errorLogsFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="monitoring.errorLogs.filterPlaceholder" autocomplete="off" />
<span id="errorLogsFilterCount" class="mapsMirFilterCount">0</span>
</div>
<div class="mapsMirTableWrap">
<table class="mapsMirTable mapsMirTable--errorLogs" id="errorLogsTable">
<thead>
<tr>
<th data-i18n="monitoring.errorLogs.colDescription">Description</th>
<th data-i18n="monitoring.errorLogs.colModule">Module</th>
<th data-i18n="monitoring.errorLogs.colTime">Time</th>
<th class="mapsMirThFunctions" data-i18n="monitoring.errorLogs.colFunctions">Functions</th>
</tr>
</thead>
<tbody id="errorLogsList"></tbody>
</table>
<div id="errorLogsListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.errorLogs.empty">No errors logged.</div>
</div>
</div>
<div id="monitoringHardwareView" class="mapsMirPage" hidden>
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="monitoring.hardware.title">Hardware health</h1>
<p class="mapsMirSubtitle" data-i18n="monitoring.hardware.subtitle">Robot hardware component status.</p>
</div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="hardwareRefreshBtn"><span data-i18n="monitoring.refresh">Refresh</span></button>
</div>
</header>
<div id="hardwareGroupsList" class="monHwList"></div>
<div id="hardwareListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.hardware.empty">No hardware data.</div>
</div>
<div id="monitoringSafetyView" class="mapsMirPage" hidden>
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="monitoring.safety.title">Safety system</h1>
<p class="mapsMirSubtitle" data-i18n="monitoring.safety.subtitle">Live emergency stop and laser scanner status.</p>
</div>
</header>
<div class="monSafetyGrid" id="safetyGrid">
<div class="monSafetyCard" id="safetyEstopCard">
<h2 class="monSafetyCardTitle" data-i18n="monitoring.safety.estop">Emergency stop</h2>
<div class="monSafetyStatus" id="safetyEstopStatus"></div>
</div>
<div class="monSafetyCard" id="safetyFrontCard">
<h2 class="monSafetyCardTitle" data-i18n="monitoring.safety.frontScanner">Front scanner</h2>
<div class="monSafetyStatus" id="safetyFrontStatus"></div>
</div>
<div class="monSafetyCard" id="safetyRearCard">
<h2 class="monSafetyCardTitle" data-i18n="monitoring.safety.rearScanner">Rear scanner</h2>
<div class="monSafetyStatus" id="safetyRearStatus"></div>
</div>
</div>
</div>
<div id="monitoringPlaceholderView" class="mapsMirPage" hidden>
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" id="monitoringPlaceholderTitle"></h1>
<p class="mapsMirSubtitle">
<span id="monitoringPlaceholderSubtitle"></span>
</p>
</div>
</header>
<div class="mapsMirTableWrap">
<p class="mapsMirEmpty" id="monitoringPlaceholderBody" data-i18n="monitoring.comingSoon">Tính năng đang phát triển.</p>
</div>
</div>
<div id="monitoringMissionLogView" class="mapsMirPage" hidden>
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="monitoring.missionLog.title">Mission log</h1>
<p class="mapsMirSubtitle">
<span data-i18n="monitoring.missionLog.subtitle">Missions executed and currently running.</span>
<button type="button" class="mapsMirHelpBtn" id="missionLogHelpBtn" data-i18n-title="monitoring.missionLog.helpTitle" aria-label="Help">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
</button>
</p>
</div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="missionLogRefreshBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M11 2v3H8M3 12V9h3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M2.5 5.5A5 5 0 0 1 11 3.5l1.5 1.5M12.5 8.5A5 5 0 0 1 3.5 10.5L2 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
<span data-i18n="monitoring.refresh">Refresh</span>
</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline mapsMirBtn--danger" id="missionLogClearHistoryBtn">
<span data-i18n="monitoring.missionLog.clearHistory">Clear history</span>
</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="missionLogClearFiltersBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span data-i18n="monitoring.clearFilters">Clear filters</span>
</button>
</div>
</header>
<div class="mapsMirFilterBar">
<label class="mapsMirFilterLabel" for="missionLogFilterInput" data-i18n="monitoring.filterLabel">Filter:</label>
<input type="search" id="missionLogFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="monitoring.missionLog.filterPlaceholder" placeholder="Filter by mission or state..." autocomplete="off" />
<span id="missionLogFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
<div class="mapsMirPager">
<button type="button" class="mapsMirPageBtn" id="missionLogPageFirst" aria-label="First page">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="missionLogPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="missionLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="missionLogPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="missionLogPageLast" aria-label="Last page">&raquo;</button>
</div>
</div>
<div class="mapsMirTableWrap">
<table class="mapsMirTable mapsMirTable--missionLog" id="missionLogTable">
<thead>
<tr>
<th class="monMirThIcon" aria-hidden="true"></th>
<th data-i18n="monitoring.missionLog.colMission">Mission</th>
<th data-i18n="monitoring.missionLog.colState">State</th>
<th data-i18n="monitoring.missionLog.colMessage">Message</th>
<th data-i18n="monitoring.missionLog.colStart">Start time</th>
<th data-i18n="monitoring.missionLog.colRanFor">Ran for</th>
<th data-i18n="monitoring.missionLog.colStartedBy">Started by</th>
<th class="mapsMirThFunctions" data-i18n="monitoring.missionLog.colFunctions">Functions</th>
</tr>
</thead>
<tbody id="missionLogList"></tbody>
</table>
<div id="missionLogListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.missionLog.empty">No missions in the log yet.</div>
</div>
</div>
<div id="monitoringActionLogView" class="mapsMirPage" hidden>
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<button type="button" class="mapsMirBtn mapsMirBtn--outline monitoringActionLogBack" id="actionLogBackBtn">
<span data-i18n="monitoring.actionLog.back">← Mission log</span>
</button>
<h1 class="mapsMirTitle" id="actionLogTitle" data-i18n="monitoring.actionLog.title">Mission action log</h1>
<p class="mapsMirSubtitle">
<span id="actionLogSubtitle"></span>
</p>
</div>
<div class="mapsMirHeaderActions">
<a class="mapsMirBtn mapsMirBtn--outline" id="actionLogDownloadBtn" href="#" download>
<span data-i18n="monitoring.actionLog.download">Download</span>
</a>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="actionLogClearFiltersBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span data-i18n="monitoring.clearFilters">Clear filters</span>
</button>
</div>
</header>
<div class="mapsMirFilterBar">
<label class="mapsMirFilterLabel" for="actionLogFilterInput" data-i18n="monitoring.filterLabel">Filter:</label>
<input type="search" id="actionLogFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="monitoring.actionLog.filterPlaceholder" placeholder="Filter by action, state or message..." autocomplete="off" />
<span id="actionLogFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
<div class="mapsMirPager">
<button type="button" class="mapsMirPageBtn" id="actionLogPageFirst" aria-label="First page">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="actionLogPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="actionLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="actionLogPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="actionLogPageLast" aria-label="Last page">&raquo;</button>
</div>
</div>
<div class="mapsMirTableWrap">
<table class="mapsMirTable mapsMirTable--actionLog" id="actionLogTable">
<thead>
<tr>
<th class="monMirThIcon" aria-hidden="true"></th>
<th data-i18n="monitoring.actionLog.colAction">Action</th>
<th data-i18n="monitoring.actionLog.colState">State</th>
<th data-i18n="monitoring.actionLog.colMessage">Message</th>
<th data-i18n="monitoring.actionLog.colStart">Start time</th>
<th data-i18n="monitoring.actionLog.colRanFor">Ran for</th>
</tr>
</thead>
<tbody id="actionLogList"></tbody>
</table>
<div id="actionLogListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.actionLog.empty">No actions logged for this mission.</div>
</div>
</div>
</div>
<div class="page" id="pageHelp" data-page-content="help" hidden>
@@ -2783,6 +3132,8 @@ GET /api/v2.0.0/status</pre>
<script src="/topbar.js"></script>
<script src="/dashboard.js"></script>
<script src="/integrations.js"></script>
<script src="/settings.js"></script>
<script src="/monitoring.js"></script>
<script src="/app.js"></script>
</body>
</html>

927
www/monitoring.js Normal file
View File

@@ -0,0 +1,927 @@
(() => {
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 });
})();

View File

@@ -26,10 +26,20 @@
],
},
monitoring: {
items: [{ section: "monitoring-log", page: "monitoring" }],
items: [
{ section: "analytics", page: "monitoring" },
{ section: "monitoring-log", page: "monitoring" },
{ section: "error-logs", page: "monitoring" },
{ section: "hardware-health", page: "monitoring" },
{ section: "safety-system", page: "monitoring" },
{ section: "mission-log", page: "monitoring" },
],
},
system: {
items: [{ section: "integrations", page: "integrations" }],
items: [
{ section: "settings", page: "settings" },
{ section: "integrations", page: "integrations" },
],
},
help: {
items: [{ section: "help-api", page: "help" }],
@@ -49,7 +59,8 @@
"path-guides": { module: "setup", section: "path-guides" },
users: { module: "setup", section: "users" },
integrations: { module: "system", section: "integrations" },
monitoring: { module: "monitoring", section: "monitoring-log" },
settings: { module: "system", section: "settings" },
monitoring: { module: "monitoring", section: "analytics" },
help: { module: "help", section: "help-api" },
};
@@ -191,6 +202,7 @@
updateRailUI();
navigateToPage(page);
if (page === "dashboard") window.DashboardApp?.handleNav?.(section);
if (page === "monitoring") window.MonitoringApp?.showSection?.(section);
}
function syncDashboardSection(section) {
@@ -212,6 +224,7 @@
saveState();
updateRailUI();
if (page === "dashboard") window.DashboardApp?.handleNav?.(activeSection);
if (page === "monitoring") window.MonitoringApp?.showSection?.(activeSection);
}
function toggleFlyout() {
@@ -310,5 +323,6 @@
selectSection,
toggleFlyout,
refreshFlyout,
getActiveSection: () => activeSection,
};
})();

148
www/settings.js Normal file
View File

@@ -0,0 +1,148 @@
(() => {
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const statusEl = el("settingsStatusText");
const inputMissionRuns = el("settingsRetentionMissionRuns");
const inputSystemLog = el("settingsRetentionSystemLog");
const inputErrorLogs = el("settingsRetentionErrorLogs");
const selectLogLevel = el("settingsLogLevel");
const gridEl = el("settingsTileGrid");
const gridViewEl = el("settingsGridView");
const detailViewEl = el("settingsDetailView");
function setStatus(text, kind = "info") {
if (!statusEl) return;
statusEl.textContent = text || "";
statusEl.classList.toggle("mapsMirNote--ok", kind === "ok");
statusEl.classList.toggle("mapsMirNote--error", kind === "error");
}
const TILE_SVGS = {
mapping: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6l6-2 4 2 6-2v14l-6 2-4-2-6 2V6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M10 4v14M14 6v14" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>`,
warning: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l10 18H2L12 3z" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 9v5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="17" r="1" fill="currentColor"/></svg>`,
gear: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15.2a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4z" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M19.4 15a8.6 8.6 0 0 0 .1-2l2-1.2-2-3.4-2.3.7a8.5 8.5 0 0 0-1.7-1L15 5h-4l-.5 2.1a8.5 8.5 0 0 0-1.7 1l-2.3-.7-2 3.4L6.6 13a8.6 8.6 0 0 0 .1 2l-2 1.2 2 3.4 2.3-.7c.5.4 1.1.7 1.7 1L11 22h4l.5-2.1c.6-.3 1.2-.6 1.7-1l2.3.7 2-3.4-2-1.2z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
battery: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="7" width="16" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M21 10v4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M6 10h8v4H6z" fill="currentColor"/></svg>`,
calendar: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="15" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 3v4M16 3v4M4 9h16" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
wifi: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 8c5-4 15-4 20 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M5 12c3.5-2.7 10.5-2.7 14 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M8.5 15.5c2-1.5 5-1.5 7 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="19" r="1.3" fill="currentColor"/></svg>`,
};
const TILES = [
{ id: "mapping", title: "settings.tile.mapping", sub: "settings.tile.mappingSub", icon: "mapping", action: "coming_soon" },
{ id: "error_handling", title: "settings.tile.errorHandling", sub: "settings.tile.errorHandlingSub", icon: "warning", action: "coming_soon" },
{ id: "battery", title: "settings.tile.battery", sub: "settings.tile.batterySub", icon: "battery", action: "coming_soon" },
{ id: "wifi", title: "settings.tile.wifi", sub: "settings.tile.wifiSub", icon: "wifi", action: "coming_soon" },
{ id: "date_time", title: "settings.tile.dateTime", sub: "settings.tile.dateTimeSub", icon: "calendar", action: "coming_soon" },
{ id: "advanced", title: "settings.tile.advanced", sub: "settings.tile.advancedSub", icon: "gear", action: "open_logging_retention" },
];
function showGrid() {
if (gridViewEl) gridViewEl.hidden = false;
if (detailViewEl) detailViewEl.hidden = true;
setStatus("");
}
function showDetail() {
if (gridViewEl) gridViewEl.hidden = true;
if (detailViewEl) detailViewEl.hidden = false;
}
function renderGrid() {
if (!gridEl) return;
gridEl.innerHTML = TILES.map((tile) => {
const disabled = tile.action === "coming_soon";
return `
<button type="button" class="settingsMirTile${disabled ? " is-disabled" : ""}" data-settings-tile="${tile.id}" ${disabled ? "disabled" : ""}>
<div class="settingsMirTileIcon">${TILE_SVGS[tile.icon] || ""}</div>
<div class="settingsMirTileTitle">${t(tile.title)}</div>
<div class="settingsMirTileSub">${t(tile.sub)}</div>
</button>`;
}).join("");
}
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 toInt(v, fallback) {
const n = Number(v);
if (!Number.isFinite(n)) return fallback;
return Math.round(n);
}
async function refresh() {
setStatus(t("settings.loading"));
const data = await apiJson("/api/settings");
const s = data && typeof data === "object" ? data : {};
if (inputMissionRuns) inputMissionRuns.value = String(toInt(s.retention_mission_runs, 2000));
if (inputSystemLog) inputSystemLog.value = String(toInt(s.retention_system_log, 2000));
if (inputErrorLogs) inputErrorLogs.value = String(toInt(s.retention_error_logs, 200));
if (selectLogLevel) selectLogLevel.value = String(s.log_level || "info");
setStatus(t("settings.loaded"), "ok");
}
async function apply() {
const payload = {
retention_mission_runs: toInt(inputMissionRuns?.value, 2000),
retention_system_log: toInt(inputSystemLog?.value, 2000),
retention_error_logs: toInt(inputErrorLogs?.value, 200),
log_level: String(selectLogLevel?.value || "info"),
};
setStatus(t("settings.saving"));
const res = await apiJson("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const ok = res && typeof res === "object";
setStatus(ok ? t("settings.saved") : t("settings.saveFailed"), ok ? "ok" : "error");
await refresh();
}
function bind() {
renderGrid();
gridEl?.addEventListener("click", (evt) => {
const btn = evt.target.closest("[data-settings-tile]");
if (!btn?.dataset.settingsTile) return;
const tile = TILES.find((x) => x.id === btn.dataset.settingsTile);
if (!tile) return;
if (tile.action === "open_logging_retention") {
showDetail();
refresh().catch((e) => setStatus(e.message, "error"));
}
});
el("settingsRefreshBtn")?.addEventListener("click", () => refresh().catch((e) => setStatus(e.message, "error")));
el("settingsApplyBtn")?.addEventListener("click", () => apply().catch((e) => setStatus(e.message, "error")));
el("settingsBackBtn")?.addEventListener("click", () => showGrid());
}
function onPageShow() {
showGrid();
refresh().catch((e) => setStatus(e.message, "error"));
}
function onPageHide() {}
window.SettingsApp = { onPageShow, onPageHide, refresh };
function boot() {
bind();
}
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
})();

View File

@@ -3503,6 +3503,62 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
gap: 8px;
}
.settingsMirGridWrap {
width: 100%;
}
.settingsMirGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.settingsMirTile {
display: grid;
grid-template-rows: 54px auto auto;
place-items: center;
gap: 6px;
padding: 16px 14px 14px;
border-radius: 6px;
border: 1px solid #e5e7eb;
background: #fff;
cursor: pointer;
text-align: center;
min-height: 138px;
}
.settingsMirTile:hover {
border-color: #cbd5e1;
box-shadow: 0 1px 10px rgba(2, 6, 23, 0.08);
}
.settingsMirTile:disabled,
.settingsMirTile.is-disabled {
opacity: 0.55;
cursor: default;
box-shadow: none;
}
.settingsMirTileIcon {
width: 44px;
height: 44px;
color: var(--mir-accent, #22c55e);
display: grid;
place-items: center;
}
.settingsMirTileTitle {
font-size: 14px;
font-weight: 700;
color: #111827;
}
.settingsMirTileSub {
font-size: 12px;
color: #6b7280;
line-height: 1.2;
}
.mapsMirHelpBtn {
display: inline-flex;
align-items: center;
@@ -3940,6 +3996,139 @@ body.auth-readonly-io-modules .ioModulesMirRow .ioModuleDeleteBtn { pointer-even
body.auth-readonly-paths .pathsMirRow .mapsMirIconBtn--danger { pointer-events: none; opacity: 0.55; }
.mapEditorPathPreview { filter: drop-shadow(0 0 2px rgba(37, 99, 235, 0.45)); }
.mapsMirTable--missionLog thead th.monMirThIcon,
.mapsMirTable--actionLog thead th.monMirThIcon { width: 52px; }
.monMirIcon { color: var(--mir-accent, #2563eb); display: block; }
.monMirTdIcon { width: 52px; text-align: center; }
.monMirMessageCell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.monMirState {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.monMirState--ok { background: #e8f5e9; color: #2e7d32; }
.monMirState--running { background: #e3f2fd; color: #1565c0; }
.monMirState--warn { background: #fff8e1; color: #f57f17; }
.monMirState--error { background: #ffebee; color: #c62828; }
.monMirState--user { background: #f3e5f5; color: #6a1b9a; }
.monMirRow--current { background: rgba(37, 99, 235, 0.06); }
.monitoringActionLogBack { margin-bottom: 8px; }
.monMirStateDot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
}
.monMirStateDot--ok { background: #2e7d32; }
.monMirStateDot--warn { background: #f57f17; }
.monMirStateDot--error { background: #c62828; }
.monMirStateDot--info { background: #1565c0; }
.mapsMirTable--systemLog thead th.monMirThState { width: 52px; }
.monAnalyticsToolbar {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
align-items: flex-end;
margin-bottom: 16px;
padding: 12px 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.monAnalyticsDates, .monAnalyticsOptions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.monAnalyticsPresets { display: flex; flex-wrap: wrap; gap: 6px; }
.monAnalyticsLabel { font-size: 12px; font-weight: 600; color: #475569; }
.monAnalyticsInput, .monAnalyticsSelect {
padding: 6px 10px;
border: 1px solid #cbd5e1;
border-radius: 4px;
font-size: 13px;
}
.monAnalyticsSummary { margin-bottom: 8px; }
.monAnalyticsTotal { font-size: 14px; font-weight: 600; color: #1e293b; }
.monAnalyticsChartWrap {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 12px;
min-height: 200px;
}
.monAnalyticsChart { width: 100%; height: auto; display: block; }
.monAnalyticsBar { fill: #2563eb; }
.monAnalyticsAxis { stroke: #94a3b8; stroke-width: 1; }
.monAnalyticsLabel { font-size: 10px; fill: #64748b; }
.monHwList { display: flex; flex-direction: column; gap: 8px; }
.monHwGroup {
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
overflow: hidden;
}
.monHwGroupHeader {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 12px 16px;
border: none;
background: #f8fafc;
cursor: pointer;
text-align: left;
font-size: 14px;
}
.monHwGroupArrow { color: #64748b; width: 14px; }
.monHwGroupName { font-weight: 600; flex: 1; }
.monHwStatusDot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.monHwStatusDot.monHwStatus--ok, .monHwGroupBadge.monHwStatus--ok { background: #2e7d32; color: #fff; }
.monHwStatusDot.monHwStatus--warn, .monHwGroupBadge.monHwStatus--warn { background: #f57f17; color: #fff; }
.monHwStatusDot.monHwStatus--error, .monHwGroupBadge.monHwStatus--error { background: #c62828; color: #fff; }
.monHwGroupBadge {
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
}
.monHwGroupBody { padding: 8px 16px 12px 40px; border-top: 1px solid #e2e8f0; }
.monHwComponent {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
font-size: 13px;
}
.monHwComponentName { font-weight: 500; min-width: 160px; }
.monHwComponentMsg { color: #64748b; }
.monSafetyGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
max-width: 900px;
}
.monSafetyCard {
border: 2px solid #e2e8f0;
border-radius: 8px;
padding: 20px;
background: #fff;
text-align: center;
}
.monSafetyCard--ok { border-color: #2e7d32; background: #f1f8f4; }
.monSafetyCard--bad { border-color: #c62828; background: #fff5f5; }
.monSafetyCardTitle { font-size: 15px; font-weight: 600; margin: 0 0 12px; }
.monSafetyStatus { font-size: 18px; font-weight: 700; }
.monSafetyCard--ok .monSafetyStatus { color: #2e7d32; }
.monSafetyCard--bad .monSafetyStatus { color: #c62828; }
.transMirIcon {
color: var(--mir-green, #5cb85c);
display: block;