This commit is contained in:
@@ -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);
|
||||
|
||||
306
www/i18n.js
306
www/i18n.js
@@ -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.",
|
||||
|
||||
367
www/index.html
367
www/index.html
@@ -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">«</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="systemLogPagePrev">‹</button>
|
||||
<span id="systemLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||
<button type="button" class="mapsMirPageBtn" id="systemLogPageNext">›</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="systemLogPageLast">»</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">«</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="missionLogPagePrev" aria-label="Previous page">‹</button>
|
||||
<span id="missionLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||
<button type="button" class="mapsMirPageBtn" id="missionLogPageNext" aria-label="Next page">›</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="missionLogPageLast" aria-label="Last page">»</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">«</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="actionLogPagePrev" aria-label="Previous page">‹</button>
|
||||
<span id="actionLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||
<button type="button" class="mapsMirPageBtn" id="actionLogPageNext" aria-label="Next page">›</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="actionLogPageLast" aria-label="Last page">»</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
927
www/monitoring.js
Normal 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, "&")
|
||||
.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 });
|
||||
})();
|
||||
20
www/nav.js
20
www/nav.js
@@ -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
148
www/settings.js
Normal 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 });
|
||||
})();
|
||||
|
||||
189
www/style.css
189
www/style.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user