1532 lines
54 KiB
JavaScript
1532 lines
54 KiB
JavaScript
(() => {
|
|
const STORAGE_KEY_V3 = "phenikaax_dashboard_v3";
|
|
const STORAGE_KEY_V2 = "phenikaax_dashboard_v2";
|
|
const PAGE_SIZE = 10;
|
|
const DEFAULT_ID = "dashboard_default";
|
|
const GRID_COLS = 12;
|
|
const GRID_ROW_PX = 52;
|
|
const GRID_GAP = 10;
|
|
const DEFAULT_W = 4;
|
|
const DEFAULT_H = 3;
|
|
const MIN_W = 2;
|
|
const MIN_H = 2;
|
|
const MAX_W = 12;
|
|
const MAX_H = 8;
|
|
|
|
let dragSession = null;
|
|
let resizeSession = null;
|
|
|
|
const el = (id) => document.getElementById(id);
|
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
|
|
|
const listViewEl = el("dashboardListView");
|
|
const createViewEl = el("dashboardCreateView");
|
|
const designerViewEl = el("dashboardDesignerView");
|
|
const gridEl = el("dashboardGrid");
|
|
const designerEmptyEl = el("dashboardDesignerEmpty");
|
|
const tableBodyEl = el("dashboardTableBody");
|
|
const tableWrapEl = el("dashboardTableWrap");
|
|
const filterInputEl = el("dashboardFilterInput");
|
|
const listCountEl = el("dashboardListCount");
|
|
const pageLabelEl = el("dashboardPageLabel");
|
|
const designerTitleEl = el("dashboardDesignerTitle");
|
|
const designerToolbarEl = el("dashboardDesignerToolbar");
|
|
const editModeBtnEl = el("dashboardEditModeBtn");
|
|
const saveBtnEl = el("dashboardSaveBtn");
|
|
let activeWidgetTab = "missions";
|
|
const editDialogEl = el("dashboardEditDialog");
|
|
const permissionsDialogEl = el("dashboardPermissionsDialog");
|
|
const addDialogEl = el("dashboardAddWidgetDialog");
|
|
const editWidgetDialogEl = el("dashboardEditWidgetDialog");
|
|
const addTypeEl = el("dashboardWidgetType");
|
|
const addFieldsEl = el("dashboardAddWidgetFields");
|
|
const editFieldsEl = el("dashboardEditWidgetFields");
|
|
const editWidgetIdEl = el("dashboardEditWidgetId");
|
|
const editWidgetTypeEl = el("dashboardEditWidgetType");
|
|
|
|
const store = {
|
|
dashboards: [],
|
|
activeDashboardId: null,
|
|
view: "list",
|
|
filter: "",
|
|
page: 1,
|
|
editMode: false,
|
|
pollActive: false,
|
|
queueUnsub: null,
|
|
userGroups: [],
|
|
maps: [],
|
|
mapsLoaded: false,
|
|
robotPose: null,
|
|
robotPoseAt: 0,
|
|
mapPollTimer: null,
|
|
};
|
|
|
|
function hasMapWidget(widgets = activeWidgets()) {
|
|
return widgets.some((w) => w.type === "map" || w.type === "map_locked");
|
|
}
|
|
|
|
async function ensureMapsLoaded(force = false) {
|
|
if (store.mapsLoaded && !force) return store.maps;
|
|
try {
|
|
const res = await fetch("/api/maps", { credentials: "include" });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
store.maps = Array.isArray(data.maps) ? data.maps : [];
|
|
} else {
|
|
store.maps = [];
|
|
}
|
|
} catch {
|
|
store.maps = [];
|
|
}
|
|
store.mapsLoaded = true;
|
|
return store.maps;
|
|
}
|
|
|
|
async function fetchRobotPose(force = false) {
|
|
const now = Date.now();
|
|
if (!force && store.robotPoseAt && now - store.robotPoseAt < 1200) return store.robotPose;
|
|
try {
|
|
const res = await fetch("/api/robot/status", { credentials: "include" });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const pose = data.pose && typeof data.pose === "object" ? data.pose : {};
|
|
store.robotPose = {
|
|
x: Number(pose.x) || 0,
|
|
y: Number(pose.y) || 0,
|
|
yaw: Number(pose.yaw) || 0,
|
|
mapId: data.active_map_id || null,
|
|
};
|
|
}
|
|
} catch {
|
|
/* keep last pose */
|
|
}
|
|
store.robotPoseAt = Date.now();
|
|
return store.robotPose;
|
|
}
|
|
|
|
function mapMeta(map) {
|
|
return {
|
|
resolution: Number(map?.resolution) || 0.05,
|
|
originX: Number(map?.origin_x) || 0,
|
|
originY: Number(map?.origin_y) || 0,
|
|
};
|
|
}
|
|
|
|
function worldToPixel(map, imgW, imgH, wx, wy) {
|
|
const { resolution, originX, originY } = mapMeta(map);
|
|
return {
|
|
x: (wx - originX) / resolution,
|
|
y: imgH - (wy - originY) / resolution,
|
|
};
|
|
}
|
|
|
|
function defaultMapPose(map, imgW, imgH) {
|
|
const { resolution, originX, originY } = mapMeta(map);
|
|
return {
|
|
x: originX + (imgW * resolution) / 2,
|
|
y: originY + (imgH * resolution) / 2,
|
|
yaw: 0,
|
|
};
|
|
}
|
|
|
|
function resolveWidgetMap(widget) {
|
|
const preferred = normalizeStr(widget.map_id) || store.robotPose?.mapId || "";
|
|
if (preferred) {
|
|
const hit = store.maps.find((m) => m.id === preferred);
|
|
if (hit) return hit;
|
|
}
|
|
return store.maps[0] || null;
|
|
}
|
|
|
|
function poseForWidget(map, imgW, imgH) {
|
|
const pose = store.robotPose;
|
|
if (pose && (!pose.mapId || pose.mapId === map.id)) {
|
|
return { x: pose.x, y: pose.y, yaw: pose.yaw };
|
|
}
|
|
return defaultMapPose(map, imgW, imgH);
|
|
}
|
|
|
|
function mapOptions(selected = "", includeActive = true) {
|
|
const opts = [];
|
|
if (includeActive) {
|
|
opts.push(
|
|
`<option value="" ${!selected ? "selected" : ""}>${escapeHtml(t("dashboard.widget.mapActive"))}</option>`
|
|
);
|
|
}
|
|
store.maps.forEach((map) => {
|
|
opts.push(
|
|
`<option value="${escapeHtml(map.id)}" ${map.id === selected ? "selected" : ""}>${escapeHtml(map.name || map.id)}</option>`
|
|
);
|
|
});
|
|
return opts.join("");
|
|
}
|
|
|
|
function mapImageUrl(map) {
|
|
if (!map?.id || !map.image_file) return null;
|
|
return `/api/maps/${encodeURIComponent(map.id)}/image`;
|
|
}
|
|
|
|
function applyMapLayout(viewportEl, map, locked, pose, imgW, imgH) {
|
|
if (!viewportEl || !map) return;
|
|
const width = imgW || 800;
|
|
const height = imgH || 600;
|
|
const px = worldToPixel(map, width, height, pose.x, pose.y);
|
|
const vw = viewportEl.clientWidth || 1;
|
|
const vh = viewportEl.clientHeight || 1;
|
|
const layer = viewportEl.querySelector("[data-map-layer]");
|
|
const marker = viewportEl.querySelector("[data-map-marker]");
|
|
if (!layer || !marker) return;
|
|
|
|
if (locked) {
|
|
const scale = Math.max(vw / width, vh / height) * 1.35;
|
|
const tx = vw / 2 - px.x * scale;
|
|
const ty = vh / 2 - px.y * scale;
|
|
layer.style.width = `${width}px`;
|
|
layer.style.height = `${height}px`;
|
|
layer.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
|
|
marker.style.left = `${vw / 2}px`;
|
|
marker.style.top = `${vh / 2}px`;
|
|
} else {
|
|
const scale = Math.min(vw / width, vh / height);
|
|
const offsetX = (vw - width * scale) / 2;
|
|
const offsetY = (vh - height * scale) / 2;
|
|
layer.style.width = `${width}px`;
|
|
layer.style.height = `${height}px`;
|
|
layer.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
|
marker.style.left = `${offsetX + px.x * scale}px`;
|
|
marker.style.top = `${offsetY + px.y * scale}px`;
|
|
}
|
|
marker.style.transform = `translate(-50%, -50%) rotate(${pose.yaw}rad)`;
|
|
viewportEl.dataset.mapId = map.id;
|
|
}
|
|
|
|
function mountMapViewport(viewportEl, map, locked) {
|
|
const imgUrl = mapImageUrl(map);
|
|
const label = escapeHtml(map.name || map.id);
|
|
viewportEl.classList.toggle("dashboardMapViewport--locked", locked);
|
|
viewportEl.innerHTML = imgUrl
|
|
? `
|
|
<div class="dashboardMapLayer" data-map-layer>
|
|
<img class="dashboardMapImage" data-map-image src="${escapeHtml(imgUrl)}" alt="${label}" draggable="false" />
|
|
</div>
|
|
<div class="dashboardMapMarker" data-map-marker aria-hidden="true"></div>`
|
|
: `
|
|
<div class="dashboardMapLayer dashboardMapLayer--grid" data-map-layer>
|
|
<div class="dashboardMapGridFallback">
|
|
<span class="dashboardMapGridTitle">${label}</span>
|
|
<span class="mutedNote">${escapeHtml(t("dashboard.widget.mapNoImage"))}</span>
|
|
</div>
|
|
</div>
|
|
<div class="dashboardMapMarker" data-map-marker aria-hidden="true"></div>`;
|
|
|
|
const imgEl = viewportEl.querySelector("[data-map-image]");
|
|
const layout = () => {
|
|
const w = imgEl?.naturalWidth || 800;
|
|
const h = imgEl?.naturalHeight || 600;
|
|
const pose = poseForWidget(map, w, h);
|
|
applyMapLayout(viewportEl, map, locked, pose, w, h);
|
|
};
|
|
|
|
if (imgEl) {
|
|
if (imgEl.complete) layout();
|
|
else {
|
|
imgEl.addEventListener("load", layout);
|
|
imgEl.addEventListener("error", () => {
|
|
viewportEl.innerHTML = `<div class="dashboardMapEmpty">${escapeHtml(t("dashboard.widget.mapImageError"))}</div>`;
|
|
});
|
|
}
|
|
} else {
|
|
layout();
|
|
}
|
|
|
|
if (!viewportEl.dataset.roObserved) {
|
|
viewportEl.dataset.roObserved = "1";
|
|
if (typeof ResizeObserver !== "undefined") {
|
|
const ro = new ResizeObserver(() => layout());
|
|
ro.observe(viewportEl);
|
|
viewportEl._mapRo = ro;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function layoutMapWidget(widget, bodyEl, locked) {
|
|
let viewportEl = bodyEl.querySelector("[data-map-viewport]");
|
|
if (!viewportEl) {
|
|
bodyEl.innerHTML = `<div class="dashboardMapViewport${locked ? " dashboardMapViewport--locked" : ""}" data-map-viewport></div>`;
|
|
viewportEl = bodyEl.querySelector("[data-map-viewport]");
|
|
}
|
|
|
|
await ensureMapsLoaded();
|
|
await fetchRobotPose();
|
|
|
|
const map = resolveWidgetMap(widget);
|
|
if (!map) {
|
|
bodyEl.innerHTML = `<div class="dashboardMapEmpty">${escapeHtml(t("dashboard.widget.mapEmpty"))}</div>`;
|
|
return;
|
|
}
|
|
|
|
mountMapViewport(viewportEl, map, locked);
|
|
}
|
|
|
|
function refreshMapWidgets() {
|
|
activeWidgets().forEach((widget) => {
|
|
if (widget.type !== "map" && widget.type !== "map_locked") return;
|
|
const bodyEl = gridEl?.querySelector(`[data-widget-id="${widget.id}"] .dashboardWidgetBody`);
|
|
if (!bodyEl) return;
|
|
void layoutMapWidget(widget, bodyEl, widget.type === "map_locked");
|
|
});
|
|
}
|
|
|
|
function widgetTypeLabel(type) {
|
|
return t(`dashboard.widget.${type}`) || type;
|
|
}
|
|
|
|
function newId(prefix = "d") {
|
|
if (typeof crypto !== "undefined" && crypto.randomUUID) return `${prefix}_${crypto.randomUUID()}`;
|
|
return `${prefix}_${Date.now().toString(36)}`;
|
|
}
|
|
|
|
function missions() {
|
|
return window.MissionsApp || null;
|
|
}
|
|
|
|
function currentUser() {
|
|
return window.AuthApp?.getUser?.() || null;
|
|
}
|
|
|
|
function canEditDashboardsModule() {
|
|
const user = currentUser();
|
|
if (!user?.permissions) return true;
|
|
const level = user.permissions.dashboard;
|
|
return level === "write" || level === undefined;
|
|
}
|
|
|
|
function activeDashboard() {
|
|
return store.dashboards.find((d) => d.id === store.activeDashboardId) || null;
|
|
}
|
|
|
|
function dashboardCanEdit(dashboard) {
|
|
if (!dashboard) return false;
|
|
if (!canEditDashboardsModule()) return false;
|
|
const user = currentUser();
|
|
if (!user) return true;
|
|
const groups = Array.isArray(dashboard.editGroups) ? dashboard.editGroups : [];
|
|
if (!groups.length) return true;
|
|
return groups.includes(user.group_id);
|
|
}
|
|
|
|
function normalizeStr(value) {
|
|
return String(value || "").trim();
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function activeWidgets() {
|
|
return activeDashboard()?.widgets || [];
|
|
}
|
|
|
|
function setActiveWidgets(widgets) {
|
|
const db = activeDashboard();
|
|
if (db) db.widgets = widgets;
|
|
}
|
|
|
|
function clamp(n, min, max) {
|
|
return Math.min(max, Math.max(min, n));
|
|
}
|
|
|
|
function hasGridPos(widget) {
|
|
return Number.isFinite(widget?.col) && Number.isFinite(widget?.row);
|
|
}
|
|
|
|
function normalizeWidget(widget) {
|
|
if (!widget || typeof widget !== "object") return widget;
|
|
widget.w = clamp(Number(widget.w) || DEFAULT_W, MIN_W, MAX_W);
|
|
widget.h = clamp(Number(widget.h) || DEFAULT_H, MIN_H, MAX_H);
|
|
if (widget.col != null) widget.col = clamp(Math.round(Number(widget.col)), 1, GRID_COLS);
|
|
if (widget.row != null) widget.row = Math.max(1, Math.round(Number(widget.row)));
|
|
return widget;
|
|
}
|
|
|
|
function gridMetrics() {
|
|
const rect = gridEl.getBoundingClientRect();
|
|
const colW = (rect.width - GRID_GAP * (GRID_COLS - 1)) / GRID_COLS;
|
|
return { rect, colW, rowH: GRID_ROW_PX, gap: GRID_GAP };
|
|
}
|
|
|
|
function pointerToGrid(clientX, clientY) {
|
|
const { rect, colW, rowH, gap } = gridMetrics();
|
|
const x = Math.max(0, clientX - rect.left);
|
|
const y = Math.max(0, clientY - rect.top);
|
|
const col = clamp(Math.floor(x / (colW + gap)) + 1, 1, GRID_COLS);
|
|
const row = Math.max(1, Math.floor(y / (rowH + gap)) + 1);
|
|
return { col, row };
|
|
}
|
|
|
|
function rectsOverlap(c1, r1, w1, h1, c2, r2, w2, h2) {
|
|
return c1 < c2 + w2 && c1 + w1 > c2 && r1 < r2 + h2 && r1 + h1 > r2;
|
|
}
|
|
|
|
function widgetFits(widgets, ignoreId, col, row, w, h) {
|
|
if (col < 1 || row < 1 || col + w - 1 > GRID_COLS) return false;
|
|
return !widgets.some((other) => {
|
|
if (other.id === ignoreId || !hasGridPos(other)) return false;
|
|
return rectsOverlap(col, row, w, h, other.col, other.row, other.w, other.h);
|
|
});
|
|
}
|
|
|
|
function findFreeGridSpot(widgets, w, h) {
|
|
const maxRow = widgets.reduce((max, item) => {
|
|
if (!hasGridPos(item)) return max;
|
|
return Math.max(max, item.row + item.h);
|
|
}, 4);
|
|
for (let row = 1; row <= maxRow + 6; row += 1) {
|
|
for (let col = 1; col <= GRID_COLS - w + 1; col += 1) {
|
|
if (widgetFits(widgets, null, col, row, w, h)) return { col, row };
|
|
}
|
|
}
|
|
return { col: 1, row: maxRow + 1 };
|
|
}
|
|
|
|
function ensureWidgetPositions(widgets) {
|
|
let cursorCol = 1;
|
|
let cursorRow = 1;
|
|
let rowHeight = 0;
|
|
|
|
widgets.forEach((widget) => {
|
|
normalizeWidget(widget);
|
|
if (hasGridPos(widget)) return;
|
|
|
|
if (cursorCol + widget.w - 1 > GRID_COLS) {
|
|
cursorRow += rowHeight || widget.h;
|
|
cursorCol = 1;
|
|
rowHeight = 0;
|
|
}
|
|
|
|
widget.col = cursorCol;
|
|
widget.row = cursorRow;
|
|
cursorCol += widget.w;
|
|
rowHeight = Math.max(rowHeight, widget.h);
|
|
|
|
if (cursorCol > GRID_COLS) {
|
|
cursorRow += rowHeight;
|
|
cursorCol = 1;
|
|
rowHeight = 0;
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateGridCanvasHeight(widgets) {
|
|
if (!gridEl) return;
|
|
let maxRowEnd = 4;
|
|
widgets.forEach((widget) => {
|
|
if (hasGridPos(widget)) maxRowEnd = Math.max(maxRowEnd, widget.row + widget.h - 1);
|
|
});
|
|
const h = maxRowEnd * GRID_ROW_PX + Math.max(0, maxRowEnd - 1) * GRID_GAP;
|
|
gridEl.style.minHeight = `${h}px`;
|
|
}
|
|
|
|
function normalizeDashboards() {
|
|
store.dashboards.forEach((db) => {
|
|
if (!Array.isArray(db.widgets)) db.widgets = [];
|
|
ensureWidgetPositions(db.widgets);
|
|
});
|
|
}
|
|
|
|
function bootstrapDefaultDashboard(widgets = []) {
|
|
store.dashboards = [
|
|
{
|
|
id: DEFAULT_ID,
|
|
name: "Default Dashboard",
|
|
createdBy: t("dashboard.createdBy.system"),
|
|
createdByUser: null,
|
|
isDefault: true,
|
|
editGroups: ["group_administrators", "group_distributors", "group_users"],
|
|
widgets: Array.isArray(widgets) ? widgets : [],
|
|
},
|
|
];
|
|
store.activeDashboardId = DEFAULT_ID;
|
|
persistStore();
|
|
}
|
|
|
|
function migrateFromV2() {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY_V2);
|
|
if (!raw) return false;
|
|
const data = JSON.parse(raw);
|
|
const widgets = Array.isArray(data.widgets) ? data.widgets : [];
|
|
bootstrapDefaultDashboard(widgets);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function loadStoreLocal() {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY_V3);
|
|
if (!raw) {
|
|
if (!migrateFromV2()) bootstrapDefaultDashboard();
|
|
return;
|
|
}
|
|
const data = JSON.parse(raw);
|
|
store.dashboards = Array.isArray(data.dashboards) ? data.dashboards : [];
|
|
store.activeDashboardId = data.activeDashboardId || store.dashboards[0]?.id || null;
|
|
if (!store.dashboards.length) bootstrapDefaultDashboard();
|
|
else if (!store.activeDashboardId) store.activeDashboardId = store.dashboards[0].id;
|
|
normalizeDashboards();
|
|
} catch {
|
|
bootstrapDefaultDashboard();
|
|
}
|
|
}
|
|
|
|
let persistTimer = null;
|
|
|
|
async function loadStoreFromBackend() {
|
|
try {
|
|
const res = await fetch("/api/dashboards", { credentials: "include" });
|
|
if (!res.ok) {
|
|
loadStoreLocal();
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
store.dashboards = Array.isArray(data.dashboards) ? data.dashboards : [];
|
|
store.activeDashboardId = data.activeDashboardId || store.dashboards[0]?.id || null;
|
|
if (!store.dashboards.length) bootstrapDefaultDashboard();
|
|
else if (!store.activeDashboardId) store.activeDashboardId = store.dashboards[0].id;
|
|
normalizeDashboards();
|
|
} catch {
|
|
loadStoreLocal();
|
|
}
|
|
}
|
|
|
|
function persistStore() {
|
|
clearTimeout(persistTimer);
|
|
persistTimer = setTimeout(syncStoreToBackend, 400);
|
|
}
|
|
|
|
async function syncStoreToBackend() {
|
|
try {
|
|
await fetch("/api/dashboards", {
|
|
credentials: "include",
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
dashboards: store.dashboards,
|
|
activeDashboardId: store.activeDashboardId,
|
|
}),
|
|
});
|
|
} catch {
|
|
/* keep in-memory state; retry on next persist */
|
|
}
|
|
}
|
|
|
|
async function loadUserGroups() {
|
|
try {
|
|
const res = await fetch("/api/user_groups", { credentials: "include" });
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
store.userGroups = Array.isArray(data.groups) ? data.groups : [];
|
|
} catch {
|
|
store.userGroups = [];
|
|
}
|
|
}
|
|
|
|
function filteredDashboards() {
|
|
const q = normalizeStr(store.filter).toLowerCase();
|
|
const list = [...store.dashboards];
|
|
list.sort((a, b) => {
|
|
if (a.isDefault && !b.isDefault) return -1;
|
|
if (!a.isDefault && b.isDefault) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
if (!q) return list;
|
|
return list.filter((d) => d.name.toLowerCase().includes(q));
|
|
}
|
|
|
|
function paginatedDashboards() {
|
|
const all = filteredDashboards();
|
|
const totalPages = Math.max(1, Math.ceil(all.length / PAGE_SIZE));
|
|
if (store.page > totalPages) store.page = totalPages;
|
|
if (store.page < 1) store.page = 1;
|
|
const start = (store.page - 1) * PAGE_SIZE;
|
|
return { all, pageItems: all.slice(start, start + PAGE_SIZE), totalPages };
|
|
}
|
|
|
|
function renderPermissionsChecklist(container, selected = []) {
|
|
if (!container) return;
|
|
container.replaceChildren();
|
|
const selectedSet = new Set(selected);
|
|
store.userGroups.forEach((group) => {
|
|
const label = document.createElement("label");
|
|
const input = document.createElement("input");
|
|
input.type = "checkbox";
|
|
input.value = group.id;
|
|
input.checked = selectedSet.has(group.id);
|
|
label.appendChild(input);
|
|
label.appendChild(document.createTextNode(group.name || group.id));
|
|
container.appendChild(label);
|
|
});
|
|
if (!store.userGroups.length) {
|
|
const note = document.createElement("p");
|
|
note.className = "mutedNote";
|
|
note.textContent = "—";
|
|
container.appendChild(note);
|
|
}
|
|
}
|
|
|
|
function readPermissionsChecklist(container) {
|
|
const ids = [];
|
|
container?.querySelectorAll('input[type="checkbox"]:checked').forEach((node) => {
|
|
ids.push(node.value);
|
|
});
|
|
return ids;
|
|
}
|
|
|
|
function refreshNav() {
|
|
window.NavApp?.refreshFlyout?.();
|
|
}
|
|
|
|
function setView(view) {
|
|
store.view = view;
|
|
document.querySelector(".dashboardShell")?.classList.toggle("dashboardShell--designer", view === "designer");
|
|
if (listViewEl) listViewEl.hidden = view !== "list";
|
|
if (createViewEl) createViewEl.hidden = view !== "create";
|
|
if (designerViewEl) designerViewEl.hidden = view !== "designer";
|
|
if (view === "list") {
|
|
stopDashboardPoll();
|
|
renderListUI();
|
|
} else if (view === "create") {
|
|
stopDashboardPoll();
|
|
} else {
|
|
syncDesignerEditMode();
|
|
renderDesignerChrome();
|
|
renderDashboard();
|
|
startDashboardPoll();
|
|
}
|
|
}
|
|
|
|
function setActiveDashboard(id) {
|
|
if (!store.dashboards.some((d) => d.id === id)) return;
|
|
store.activeDashboardId = id;
|
|
persistStore();
|
|
refreshNav();
|
|
}
|
|
|
|
function renderListUI() {
|
|
const { all, pageItems, totalPages } = paginatedDashboards();
|
|
|
|
if (listCountEl) listCountEl.textContent = t("dashboard.list.itemsFound", { count: all.length });
|
|
if (pageLabelEl) pageLabelEl.textContent = t("dashboard.list.pageOf", { page: store.page, total: totalPages });
|
|
|
|
document.querySelectorAll(".dashboardPageBtn").forEach((btn) => {
|
|
const action = btn.dataset.pageAction;
|
|
if (action === "first" || action === "prev") btn.disabled = store.page <= 1;
|
|
else if (action === "next" || action === "last") btn.disabled = store.page >= totalPages;
|
|
});
|
|
|
|
if (tableBodyEl) {
|
|
tableBodyEl.replaceChildren();
|
|
if (!pageItems.length) {
|
|
const tr = document.createElement("tr");
|
|
tr.className = "dashboardTableEmptyRow";
|
|
tr.innerHTML = `<td colspan="4">${escapeHtml(t("dashboard.list.empty"))}</td>`;
|
|
tableBodyEl.appendChild(tr);
|
|
} else {
|
|
pageItems.forEach((dashboard) => {
|
|
const tr = document.createElement("tr");
|
|
tr.className = "dashboardTableRow";
|
|
if (dashboard.isDefault) tr.classList.add("is-default");
|
|
if (dashboard.id === store.activeDashboardId) tr.classList.add("is-active");
|
|
const canEdit = dashboardCanEdit(dashboard);
|
|
const isActive = dashboard.id === store.activeDashboardId;
|
|
tr.innerHTML = `
|
|
<td class="dashboardTableStatusCol">
|
|
${isActive ? `<span class="dashboardActiveMark" aria-label="${escapeHtml(t("dashboard.list.active"))}">✓</span>` : `<span class="dashboardActiveMark dashboardActiveMark--placeholder" aria-hidden="true"></span>`}
|
|
</td>
|
|
<td class="dashboardTableNameCell">${escapeHtml(dashboard.name)}</td>
|
|
<td class="dashboardTableCreatedCell">${escapeHtml(dashboard.createdBy || "—")}</td>
|
|
<td class="dashboardTableFuncCol">
|
|
<div class="dashboardFuncBtns">
|
|
<button type="button" class="dashboardFuncBtn" data-action="design" data-id="${escapeHtml(dashboard.id)}" title="${escapeHtml(t("dashboard.list.design"))}" aria-label="${escapeHtml(t("dashboard.list.design"))}">
|
|
<span class="dashboardFuncIcon dashboardFuncIcon--design" aria-hidden="true"></span>
|
|
</button>
|
|
<button type="button" class="dashboardFuncBtn" data-action="edit" data-id="${escapeHtml(dashboard.id)}" title="${escapeHtml(t("dashboard.list.edit"))}" aria-label="${escapeHtml(t("dashboard.list.edit"))}" ${canEdit ? "" : "disabled"}>
|
|
<span class="dashboardFuncIcon dashboardFuncIcon--edit" aria-hidden="true"></span>
|
|
</button>
|
|
<button type="button" class="dashboardFuncBtn dashboardFuncBtn--delete" data-action="delete" data-id="${escapeHtml(dashboard.id)}" title="${escapeHtml(t("dashboard.list.delete"))}" aria-label="${escapeHtml(t("dashboard.list.delete"))}" ${canEdit && !dashboard.isDefault ? "" : "disabled"}>
|
|
<span class="dashboardFuncIcon dashboardFuncIcon--delete" aria-hidden="true"></span>
|
|
</button>
|
|
</div>
|
|
</td>`;
|
|
tableBodyEl.appendChild(tr);
|
|
});
|
|
}
|
|
}
|
|
|
|
if (tableWrapEl) tableWrapEl.classList.toggle("is-empty", all.length === 0);
|
|
|
|
const createBtn = el("dashboardCreateBtn");
|
|
if (createBtn) createBtn.disabled = !canEditDashboardsModule();
|
|
}
|
|
|
|
function setWidgetTab(tab) {
|
|
activeWidgetTab = tab;
|
|
designerToolbarEl?.querySelectorAll("[data-widget-tab]").forEach((btn) => {
|
|
const active = btn.dataset.widgetTab === tab;
|
|
btn.classList.toggle("is-active", active);
|
|
btn.setAttribute("aria-selected", active ? "true" : "false");
|
|
});
|
|
designerToolbarEl?.querySelectorAll("[data-panel]").forEach((panel) => {
|
|
panel.hidden = panel.dataset.panel !== tab;
|
|
});
|
|
}
|
|
|
|
function renderDesignerChrome() {
|
|
const db = activeDashboard();
|
|
if (designerTitleEl) designerTitleEl.textContent = db?.name || "—";
|
|
const canEdit = dashboardCanEdit(db);
|
|
if (designerToolbarEl) designerToolbarEl.hidden = !canEdit || !store.editMode;
|
|
if (saveBtnEl) saveBtnEl.hidden = !canEdit || !store.editMode;
|
|
if (editModeBtnEl) {
|
|
editModeBtnEl.hidden = !canEdit;
|
|
editModeBtnEl.textContent = store.editMode ? t("dashboard.editDone") : t("dashboard.editLayout");
|
|
editModeBtnEl.setAttribute("aria-pressed", store.editMode ? "true" : "false");
|
|
}
|
|
if (!designerToolbarEl?.hidden) setWidgetTab(activeWidgetTab);
|
|
}
|
|
|
|
function syncDesignerEditMode() {
|
|
/* operate mode by default — edit toggled explicitly */
|
|
}
|
|
|
|
function addWidget(type) {
|
|
const db = activeDashboard();
|
|
if (!db || !store.editMode || !dashboardCanEdit(db)) return;
|
|
const isMap = type === "map_locked" || type === "map";
|
|
const widget = normalizeWidget({
|
|
id: newId("w"),
|
|
type,
|
|
title: type === "mission_queue" ? "Mission queue" : "",
|
|
w: isMap ? 6 : DEFAULT_W,
|
|
h: isMap ? 6 : DEFAULT_H,
|
|
});
|
|
const spot = findFreeGridSpot(db.widgets, widget.w, widget.h);
|
|
widget.col = spot.col;
|
|
widget.row = spot.row;
|
|
db.widgets.push(widget);
|
|
persistStore();
|
|
renderDashboard();
|
|
if (type === "mission_button" || type === "mission_group") openEditDialog(widget.id);
|
|
else if (type === "map_locked" || type === "map") openEditDialog(widget.id);
|
|
}
|
|
|
|
function applyWidgetGridStyle(card, widget) {
|
|
normalizeWidget(widget);
|
|
card.style.setProperty("--dw", String(widget.w));
|
|
card.style.setProperty("--dh", String(widget.h));
|
|
if (hasGridPos(widget)) {
|
|
card.style.gridColumn = `${widget.col} / span ${widget.w}`;
|
|
card.style.gridRow = `${widget.row} / span ${widget.h}`;
|
|
} else {
|
|
card.style.gridColumn = `span ${widget.w}`;
|
|
card.style.gridRow = `span ${widget.h}`;
|
|
}
|
|
}
|
|
|
|
function startWidgetMove(evt, widget, card) {
|
|
if (!store.editMode || resizeSession || dragSession) return;
|
|
if (evt.button !== 0) return;
|
|
if (!hasGridPos(widget)) return;
|
|
|
|
evt.preventDefault();
|
|
const startCol = widget.col;
|
|
const startRow = widget.row;
|
|
const grab = pointerToGrid(evt.clientX, evt.clientY);
|
|
const grabColOff = grab.col - widget.col;
|
|
const grabRowOff = grab.row - widget.row;
|
|
|
|
dragSession = { widgetId: widget.id };
|
|
card.classList.add("is-dragging");
|
|
document.body.classList.add("dashboard-widget-dragging");
|
|
|
|
const onMove = (e) => {
|
|
const pt = pointerToGrid(e.clientX, e.clientY);
|
|
let col = pt.col - grabColOff;
|
|
let row = pt.row - grabRowOff;
|
|
col = clamp(col, 1, GRID_COLS - widget.w + 1);
|
|
row = Math.max(1, row);
|
|
widget.col = col;
|
|
widget.row = row;
|
|
applyWidgetGridStyle(card, widget);
|
|
updateGridCanvasHeight(activeWidgets());
|
|
};
|
|
|
|
const onUp = () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
card.classList.remove("is-dragging");
|
|
document.body.classList.remove("dashboard-widget-dragging");
|
|
dragSession = null;
|
|
|
|
const widgets = activeWidgets();
|
|
if (!widgetFits(widgets, widget.id, widget.col, widget.row, widget.w, widget.h)) {
|
|
widget.col = startCol;
|
|
widget.row = startRow;
|
|
applyWidgetGridStyle(card, widget);
|
|
} else {
|
|
persistStore();
|
|
}
|
|
updateGridCanvasHeight(widgets);
|
|
};
|
|
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
}
|
|
|
|
function startWidgetResize(evt, widget, card) {
|
|
if (!store.editMode) return;
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
const startX = evt.clientX;
|
|
const startY = evt.clientY;
|
|
const startW = widget.w;
|
|
const startH = widget.h;
|
|
const colW = gridEl.clientWidth / GRID_COLS;
|
|
|
|
const onMove = (e) => {
|
|
const dw = Math.round((e.clientX - startX) / colW);
|
|
const dh = Math.round((e.clientY - startY) / GRID_ROW_PX);
|
|
widget.w = clamp(startW + dw, MIN_W, MAX_W);
|
|
widget.h = clamp(startH + dh, MIN_H, MAX_H);
|
|
applyWidgetGridStyle(card, widget);
|
|
};
|
|
const onUp = () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
persistStore();
|
|
resizeSession = null;
|
|
};
|
|
resizeSession = { widgetId: widget.id };
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
}
|
|
|
|
function attachWidgetInteractions(card, widget) {
|
|
applyWidgetGridStyle(card, widget);
|
|
const header = card.querySelector(".dashboardWidgetHeader");
|
|
const pen = card.querySelector("[data-widget-config]");
|
|
const resize = card.querySelector("[data-widget-resize]");
|
|
|
|
pen?.addEventListener("click", (evt) => {
|
|
evt.stopPropagation();
|
|
openEditDialog(widget.id);
|
|
});
|
|
|
|
resize?.addEventListener("mousedown", (evt) => startWidgetResize(evt, widget, card));
|
|
|
|
if (!store.editMode) return;
|
|
|
|
header?.addEventListener("mousedown", (evt) => startWidgetMove(evt, widget, card));
|
|
}
|
|
|
|
function openCreateView() {
|
|
if (!canEditDashboardsModule()) return;
|
|
const nameInput = el("dashboardCreateName");
|
|
if (nameInput) nameInput.value = "";
|
|
renderPermissionsChecklist(el("dashboardCreatePermissions"), currentUser()?.group_id ? [currentUser().group_id] : []);
|
|
setView("create");
|
|
requestAnimationFrame(() => nameInput?.focus());
|
|
}
|
|
|
|
function openCreatePermissionsDialog() {
|
|
const container = el("dashboardCreatePermissions");
|
|
const current = readPermissionsChecklist(container);
|
|
const defaults = current.length ? current : currentUser()?.group_id ? [currentUser().group_id] : [];
|
|
renderPermissionsChecklist(container, defaults);
|
|
permissionsDialogEl?.showModal();
|
|
}
|
|
|
|
function closeCreateView() {
|
|
setView("list");
|
|
window.NavApp?.syncDashboardSection?.("dashboard-list");
|
|
}
|
|
|
|
function openEditDashboardDialog(id) {
|
|
const dashboard = store.dashboards.find((d) => d.id === id);
|
|
if (!dashboard || !dashboardCanEdit(dashboard)) {
|
|
alert(t("dashboard.list.noEditPermission"));
|
|
return;
|
|
}
|
|
el("dashboardEditId").value = dashboard.id;
|
|
el("dashboardEditName").value = dashboard.name;
|
|
renderPermissionsChecklist(el("dashboardEditPermissions"), dashboard.editGroups || []);
|
|
editDialogEl?.showModal();
|
|
}
|
|
|
|
function openDesignerFor(id, { edit = false } = {}) {
|
|
setActiveDashboard(id);
|
|
const db = activeDashboard();
|
|
store.editMode = edit && dashboardCanEdit(db);
|
|
if (store.editMode) setWidgetTab("missions");
|
|
setView("designer");
|
|
window.NavApp?.syncDashboardSection?.(`dashboard-${id}`);
|
|
}
|
|
|
|
function deleteDashboard(id) {
|
|
const dashboard = store.dashboards.find((d) => d.id === id);
|
|
if (!dashboard) return;
|
|
if (dashboard.isDefault) {
|
|
alert(t("dashboard.list.cannotDeleteDefault"));
|
|
return;
|
|
}
|
|
if (!dashboardCanEdit(dashboard)) {
|
|
alert(t("dashboard.list.noEditPermission"));
|
|
return;
|
|
}
|
|
if (!confirm(t("dashboard.list.deleteConfirm", { name: dashboard.name }))) return;
|
|
store.dashboards = store.dashboards.filter((d) => d.id !== id);
|
|
if (store.activeDashboardId === id) {
|
|
store.activeDashboardId = store.dashboards[0]?.id || null;
|
|
}
|
|
persistStore();
|
|
refreshNav();
|
|
renderListUI();
|
|
}
|
|
|
|
function widgetTitle(widget) {
|
|
if (widget.title) return widget.title;
|
|
return widgetTypeLabel(widget.type);
|
|
}
|
|
|
|
function missionOptions(selected) {
|
|
const list = missions()?.getMissions?.() || [];
|
|
return list
|
|
.map(
|
|
(m) =>
|
|
`<option value="${escapeHtml(m.id)}" ${m.id === selected ? "selected" : ""}>${escapeHtml(m.name)} (${escapeHtml(m.group)})</option>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function groupOptions(selected) {
|
|
const groups = missions()?.getGroups?.() || ["Missions"];
|
|
return groups
|
|
.map((g) => `<option value="${escapeHtml(g)}" ${g === selected ? "selected" : ""}>${escapeHtml(g)}</option>`)
|
|
.join("");
|
|
}
|
|
|
|
function fillTypeFields(container, type, widget = {}) {
|
|
if (!container) return;
|
|
container.innerHTML = "";
|
|
if (type === "mission_button") {
|
|
container.innerHTML = `
|
|
<div class="row rowWide">
|
|
<label>${t("dashboard.widget.field.mission")}</label>
|
|
<select data-field="mission_id">${missionOptions(widget.mission_id || "")}</select>
|
|
</div>
|
|
<div class="row rowWide">
|
|
<label>${t("dashboard.widget.field.title")}</label>
|
|
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="${escapeHtml(t("dashboard.widget.titlePlaceholder"))}" />
|
|
</div>`;
|
|
} else if (type === "mission_group") {
|
|
container.innerHTML = `
|
|
<div class="row rowWide">
|
|
<label>${t("dashboard.widget.field.group")}</label>
|
|
<select data-field="group">${groupOptions(widget.group || "Missions")}</select>
|
|
</div>
|
|
<div class="row rowWide">
|
|
<label>${t("dashboard.widget.field.title")}</label>
|
|
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" />
|
|
</div>`;
|
|
} else if (type === "mission_queue") {
|
|
container.innerHTML = `
|
|
<div class="row rowWide">
|
|
<label>${t("dashboard.widget.field.title")}</label>
|
|
<input data-field="title" type="text" value="${escapeHtml(widget.title || "Mission queue")}" />
|
|
</div>`;
|
|
} else if (type === "pause_continue") {
|
|
container.innerHTML = `
|
|
<div class="row rowWide">
|
|
<label>${t("dashboard.widget.field.title")}</label>
|
|
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" />
|
|
</div>
|
|
<p class="mutedNote">${escapeHtml(t("dashboard.widget.pauseHint"))}</p>`;
|
|
} else if (type === "mission_action_log") {
|
|
container.innerHTML = `
|
|
<div class="row rowWide">
|
|
<label>${t("dashboard.widget.field.title")}</label>
|
|
<input data-field="title" type="text" value="${escapeHtml(widget.title || "Mission action log")}" />
|
|
</div>`;
|
|
} else if (type === "logout_button") {
|
|
container.innerHTML = `
|
|
<div class="row rowWide">
|
|
<label>${t("dashboard.widget.field.title")}</label>
|
|
<input data-field="title" type="text" value="${escapeHtml(widget.title || t("dashboard.widget.logout_button"))}" />
|
|
</div>`;
|
|
} else if (type === "map_locked" || type === "map") {
|
|
container.innerHTML = `
|
|
<div class="row rowWide">
|
|
<label>${t("dashboard.widget.field.map")}</label>
|
|
<select data-field="map_id">${mapOptions(widget.map_id || "")}</select>
|
|
</div>
|
|
<div class="row rowWide">
|
|
<label>${t("dashboard.widget.field.title")}</label>
|
|
<input data-field="title" type="text" value="${escapeHtml(widget.title || widgetTypeLabel(type))}" />
|
|
</div>
|
|
<p class="mutedNote">${escapeHtml(t("dashboard.widget.mapHint"))}</p>`;
|
|
} else if (type === "robot_summary") {
|
|
container.innerHTML = `
|
|
<div class="row rowWide">
|
|
<label>${t("dashboard.widget.field.title")}</label>
|
|
<input data-field="title" type="text" value="${escapeHtml(widget.title || t("dashboard.widget.robot_summary"))}" />
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
function readFields(container) {
|
|
const out = {};
|
|
container?.querySelectorAll("[data-field]").forEach((node) => {
|
|
out[node.dataset.field] = node.value;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function renderMissionButtonWidget(widget, bodyEl) {
|
|
const m = missions()?.getMissionById?.(widget.mission_id);
|
|
const label = m?.name || t("dashboard.widget.selectMission");
|
|
bodyEl.innerHTML = `
|
|
<button type="button" class="dashboardMissionBtn" data-run-mission="${escapeHtml(widget.mission_id || "")}">
|
|
<span class="dashboardMissionBtnIcon">▶</span>
|
|
<span>${escapeHtml(label)}</span>
|
|
</button>
|
|
${!m ? `<p class="mutedNote dashboardWidgetHint">${t("dashboard.widget.configHint")}</p>` : ""}`;
|
|
bodyEl.querySelector("[data-run-mission]")?.addEventListener("click", () => {
|
|
if (!widget.mission_id) return;
|
|
missions()?.queueMission?.(widget.mission_id);
|
|
});
|
|
}
|
|
|
|
function renderMissionGroupWidget(widget, bodyEl) {
|
|
const group = widget.group || "Missions";
|
|
const list = (missions()?.getMissions?.() || []).filter((m) => m.group === group);
|
|
if (!list.length) {
|
|
bodyEl.innerHTML = `<p class="mutedNote">${t("dashboard.widget.emptyGroup", { group })}</p>`;
|
|
return;
|
|
}
|
|
bodyEl.innerHTML = `<div class="dashboardMissionGroupList"></div>`;
|
|
const listEl = bodyEl.querySelector(".dashboardMissionGroupList");
|
|
list.forEach((m) => {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "dashboardMissionGroupBtn";
|
|
btn.innerHTML = `<span class="dashboardMissionBtnIcon">▶</span><span>${escapeHtml(m.name)}</span>`;
|
|
btn.addEventListener("click", () => missions()?.queueMission?.(m.id));
|
|
listEl.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
function renderMissionQueueWidget(widget, bodyEl) {
|
|
bodyEl.innerHTML = `
|
|
<div class="dashboardQueueRunner mutedNote" data-role="runner">—</div>
|
|
<div class="dashboardQueueList" data-role="list"></div>
|
|
<p class="mutedNote dashboardQueueEmpty" data-role="empty">${t("dashboard.widget.queueEmpty")}</p>
|
|
<button type="button" class="btn subtle btnBlock dashboardQueueClear">${t("dashboard.widget.clearQueue")}</button>`;
|
|
bodyEl.querySelector(".dashboardQueueClear")?.addEventListener("click", () => missions()?.clearQueue?.());
|
|
refreshQueueWidget(bodyEl);
|
|
}
|
|
|
|
function refreshQueueWidget(bodyEl) {
|
|
const snap = missions()?.getQueueSnapshot?.();
|
|
if (!snap) return;
|
|
missions()?.renderQueueInto?.(
|
|
{
|
|
listEl: bodyEl.querySelector('[data-role="list"]'),
|
|
runnerEl: bodyEl.querySelector('[data-role="runner"]'),
|
|
emptyEl: bodyEl.querySelector('[data-role="empty"]'),
|
|
},
|
|
{ compact: true }
|
|
);
|
|
}
|
|
|
|
function renderMissionActionLogWidget(widget, bodyEl) {
|
|
const snap = missions()?.getQueueSnapshot?.();
|
|
const runner = snap?.runner || {};
|
|
const executing = (snap?.queue || []).find((e) => e.status === "executing");
|
|
const lines = [];
|
|
if (runner.current_action) {
|
|
lines.push({ message: runner.current_action, current: true });
|
|
} else if (runner.message) {
|
|
lines.push({ message: runner.message, current: true });
|
|
}
|
|
if (executing?.log && Array.isArray(executing.log)) {
|
|
executing.log
|
|
.slice(-10)
|
|
.reverse()
|
|
.forEach((entry) => {
|
|
if (entry?.message) lines.push({ message: entry.message, level: entry.level || "info" });
|
|
});
|
|
}
|
|
if (!lines.length) {
|
|
bodyEl.innerHTML = `<p class="mutedNote">${escapeHtml(t("dashboard.widget.actionLog.empty"))}</p>`;
|
|
return;
|
|
}
|
|
bodyEl.innerHTML = `<ul class="dashboardActionLogList"></ul>`;
|
|
const listEl = bodyEl.querySelector(".dashboardActionLogList");
|
|
lines.forEach((line) => {
|
|
const li = document.createElement("li");
|
|
li.className = `dashboardActionLogItem${line.current ? " is-current" : ""}${line.level ? ` level-${line.level}` : ""}`;
|
|
li.textContent = line.message;
|
|
listEl.appendChild(li);
|
|
});
|
|
}
|
|
|
|
function renderLogoutButtonWidget(widget, bodyEl) {
|
|
const label = widget.title || t("dashboard.widget.logout_button");
|
|
bodyEl.innerHTML = `<button type="button" class="dashboardLogoutBtn">${escapeHtml(label)}</button>`;
|
|
bodyEl.querySelector(".dashboardLogoutBtn")?.addEventListener("click", () => window.AuthApp?.logout?.());
|
|
}
|
|
|
|
function renderMapWidget(widget, bodyEl, locked = false) {
|
|
bodyEl.innerHTML = `<div class="dashboardMapViewport${locked ? " dashboardMapViewport--locked" : ""}" data-map-viewport>
|
|
<div class="dashboardMapLoading mutedNote">${escapeHtml(t("dashboard.widget.mapLoading"))}</div>
|
|
</div>`;
|
|
void layoutMapWidget(widget, bodyEl, locked);
|
|
}
|
|
|
|
function renderRobotSummaryWidget(widget, bodyEl) {
|
|
const title = widget.title || t("dashboard.widget.robot_summary");
|
|
bodyEl.innerHTML = `
|
|
<div class="dashboardRobotSummary">
|
|
<div class="dashboardRobotSummaryIcon" aria-hidden="true">⬡</div>
|
|
<div class="dashboardRobotSummaryMeta">
|
|
<div class="dashboardRobotSummaryName">${escapeHtml(t("app.robotName"))}</div>
|
|
<div class="dashboardRobotSummarySub mutedNote">${escapeHtml(title)}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderPauseContinueWidget(widget, bodyEl) {
|
|
const snap = missions()?.getQueueSnapshot?.();
|
|
const state = snap?.runner?.state || "idle";
|
|
const paused = state === "paused" || snap?.runner?.paused;
|
|
const running = state === "running" || paused;
|
|
|
|
if (!store.editMode) {
|
|
bodyEl.innerHTML = `
|
|
<button type="button" class="dashboardMirToggleBtn ${paused ? "is-continue" : "is-pause"}" data-pause-action="${paused ? "continue" : "pause"}" ${running ? "" : "disabled"}>
|
|
${paused ? t("dashboard.widget.continue") : t("dashboard.widget.pause")}
|
|
</button>`;
|
|
bodyEl.querySelector("[data-pause-action]")?.addEventListener("click", async (evt) => {
|
|
const action = evt.currentTarget.dataset.pauseAction;
|
|
try {
|
|
if (action === "pause") await missions()?.pauseRunner?.();
|
|
else await missions()?.continueRunner?.();
|
|
} catch (e) {
|
|
alert(e.message);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
bodyEl.innerHTML = `
|
|
<div class="dashboardRunnerControls">
|
|
<button type="button" class="dashboardPauseBtn ${paused ? "is-paused" : ""}" data-pause-action="${paused ? "continue" : "pause"}" ${running ? "" : "disabled"}>
|
|
${paused ? t("dashboard.widget.continue") : t("dashboard.widget.pause")}
|
|
</button>
|
|
<button type="button" class="dashboardCancelBtn" data-cancel-mission ${running ? "" : "disabled"}>
|
|
${t("dashboard.widget.cancelMission")}
|
|
</button>
|
|
</div>
|
|
<p class="mutedNote dashboardWidgetHint">${escapeHtml(t("dashboard.widget.pauseHint"))}</p>`;
|
|
bodyEl.querySelector("[data-pause-action]")?.addEventListener("click", async (evt) => {
|
|
const action = evt.currentTarget.dataset.pauseAction;
|
|
try {
|
|
if (action === "pause") await missions()?.pauseRunner?.();
|
|
else await missions()?.continueRunner?.();
|
|
} catch (e) {
|
|
alert(e.message);
|
|
}
|
|
});
|
|
bodyEl.querySelector("[data-cancel-mission]")?.addEventListener("click", async () => {
|
|
try {
|
|
await missions()?.cancelRunner?.();
|
|
} catch (e) {
|
|
alert(e.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderWidget(widget) {
|
|
normalizeWidget(widget);
|
|
const card = document.createElement("article");
|
|
card.className = `dashboardWidget dashboardWidget--${widget.type}`;
|
|
card.classList.toggle("dashboardWidget--operate", !store.editMode);
|
|
card.classList.toggle("dashboardWidget--edit", store.editMode);
|
|
card.dataset.widgetId = widget.id;
|
|
|
|
card.innerHTML = `
|
|
${store.editMode ? `
|
|
<div class="dashboardWidgetHeader" title="${escapeHtml(t("dashboard.designer.dragHint"))}">
|
|
<div class="dashboardWidgetTitle">${escapeHtml(widgetTitle(widget))}</div>
|
|
<span class="dashboardWidgetDragHint" aria-hidden="true">⠿</span>
|
|
</div>` : ""}
|
|
<div class="dashboardWidgetBody"></div>
|
|
<button type="button" class="dashboardWidgetPen" data-widget-config title="${escapeHtml(t("dashboard.designer.configure"))}" aria-label="${escapeHtml(t("dashboard.designer.configure"))}" ${store.editMode ? "" : "hidden"}>✎</button>
|
|
<div class="dashboardWidgetResize" data-widget-resize title="${escapeHtml(t("dashboard.designer.resize"))}" aria-label="${escapeHtml(t("dashboard.designer.resize"))}" ${store.editMode ? "" : "hidden"}>↘</div>`;
|
|
|
|
const bodyEl = card.querySelector(".dashboardWidgetBody");
|
|
switch (widget.type) {
|
|
case "mission_button":
|
|
renderMissionButtonWidget(widget, bodyEl);
|
|
break;
|
|
case "mission_group":
|
|
renderMissionGroupWidget(widget, bodyEl);
|
|
break;
|
|
case "mission_queue":
|
|
renderMissionQueueWidget(widget, bodyEl);
|
|
break;
|
|
case "pause_continue":
|
|
renderPauseContinueWidget(widget, bodyEl);
|
|
break;
|
|
case "mission_action_log":
|
|
renderMissionActionLogWidget(widget, bodyEl);
|
|
break;
|
|
case "logout_button":
|
|
renderLogoutButtonWidget(widget, bodyEl);
|
|
break;
|
|
case "map_locked":
|
|
renderMapWidget(widget, bodyEl, true);
|
|
break;
|
|
case "map":
|
|
renderMapWidget(widget, bodyEl, false);
|
|
break;
|
|
case "robot_summary":
|
|
renderRobotSummaryWidget(widget, bodyEl);
|
|
break;
|
|
default:
|
|
bodyEl.innerHTML = `<p class="mutedNote">${t("dashboard.widget.unsupported")}</p>`;
|
|
}
|
|
|
|
attachWidgetInteractions(card, widget);
|
|
return card;
|
|
}
|
|
|
|
function renderDashboard() {
|
|
if (!gridEl) return;
|
|
const widgets = activeWidgets();
|
|
gridEl.innerHTML = "";
|
|
if (designerEmptyEl) {
|
|
designerEmptyEl.hidden = widgets.length > 0;
|
|
designerEmptyEl.textContent = store.editMode
|
|
? t("dashboard.designer.emptyEdit")
|
|
: t("dashboard.designer.empty");
|
|
}
|
|
gridEl.classList.toggle("dashboardGrid--edit", store.editMode);
|
|
ensureWidgetPositions(widgets);
|
|
widgets.forEach((w) => gridEl.appendChild(renderWidget(w)));
|
|
updateGridCanvasHeight(widgets);
|
|
renderDesignerChrome();
|
|
if (hasMapWidget(widgets)) {
|
|
void ensureMapsLoaded().then(() => refreshMapWidgets());
|
|
}
|
|
}
|
|
|
|
function refreshDynamicWidgets() {
|
|
activeWidgets().forEach((widget) => {
|
|
const card = gridEl?.querySelector(`[data-widget-id="${widget.id}"]`);
|
|
if (!card) return;
|
|
const bodyEl = card.querySelector(".dashboardWidgetBody");
|
|
if (widget.type === "mission_queue") refreshQueueWidget(bodyEl);
|
|
if (widget.type === "pause_continue") renderPauseContinueWidget(widget, bodyEl);
|
|
if (widget.type === "mission_action_log") renderMissionActionLogWidget(widget, bodyEl);
|
|
});
|
|
if (hasMapWidget()) refreshMapWidgets();
|
|
}
|
|
|
|
function openEditDialog(widgetId) {
|
|
const widget = activeWidgets().find((w) => w.id === widgetId);
|
|
if (!widget) return;
|
|
editWidgetIdEl.value = widget.id;
|
|
editWidgetTypeEl.value = widgetTypeLabel(widget.type);
|
|
const open = () => {
|
|
fillTypeFields(editFieldsEl, widget.type, widget);
|
|
editWidgetDialogEl.showModal();
|
|
};
|
|
if (widget.type === "map" || widget.type === "map_locked") {
|
|
void ensureMapsLoaded().then(open);
|
|
} else {
|
|
open();
|
|
}
|
|
}
|
|
|
|
function deleteWidget(widgetId) {
|
|
if (!confirm(t("dashboard.widget.deleteConfirm"))) return;
|
|
const db = activeDashboard();
|
|
if (!db) return;
|
|
db.widgets = db.widgets.filter((w) => w.id !== widgetId);
|
|
persistStore();
|
|
renderDashboard();
|
|
editWidgetDialogEl.close();
|
|
}
|
|
|
|
function handleNav(section) {
|
|
if (section === "dashboard-list") {
|
|
setView("list");
|
|
return;
|
|
}
|
|
if (section.startsWith("dashboard-")) {
|
|
const id = section.slice("dashboard-".length);
|
|
if (store.dashboards.some((d) => d.id === id)) {
|
|
setActiveDashboard(id);
|
|
store.editMode = false;
|
|
setView("designer");
|
|
}
|
|
}
|
|
}
|
|
|
|
function getNavItems() {
|
|
const items = [{ section: "dashboard-list", page: "dashboard", label: t("nav.dashboardsList") }];
|
|
store.dashboards.forEach((dashboard) => {
|
|
items.push({
|
|
section: `dashboard-${dashboard.id}`,
|
|
page: "dashboard",
|
|
label: dashboard.name,
|
|
});
|
|
});
|
|
return items;
|
|
}
|
|
|
|
function bindEvents() {
|
|
el("dashboardCreateBtn")?.addEventListener("click", openCreateView);
|
|
el("dashboardCreateBackBtn")?.addEventListener("click", closeCreateView);
|
|
el("dashboardCreateCancelBtn")?.addEventListener("click", closeCreateView);
|
|
el("dashboardCreatePermissionsBtn")?.addEventListener("click", openCreatePermissionsDialog);
|
|
el("dashboardPermissionsForm")?.addEventListener("submit", (evt) => {
|
|
evt.preventDefault();
|
|
permissionsDialogEl?.close();
|
|
});
|
|
el("dashboardClearFiltersBtn")?.addEventListener("click", () => {
|
|
store.filter = "";
|
|
store.page = 1;
|
|
if (filterInputEl) filterInputEl.value = "";
|
|
renderListUI();
|
|
});
|
|
filterInputEl?.addEventListener("input", () => {
|
|
store.filter = filterInputEl.value;
|
|
store.page = 1;
|
|
renderListUI();
|
|
});
|
|
|
|
document.getElementById("dashboardPagination")?.addEventListener("click", (evt) => {
|
|
const btn = evt.target.closest("[data-page-action]");
|
|
if (!btn || btn.disabled) return;
|
|
const { totalPages } = paginatedDashboards();
|
|
const action = btn.dataset.pageAction;
|
|
if (action === "first") store.page = 1;
|
|
else if (action === "prev") store.page = Math.max(1, store.page - 1);
|
|
else if (action === "next") store.page = Math.min(totalPages, store.page + 1);
|
|
else if (action === "last") store.page = totalPages;
|
|
renderListUI();
|
|
});
|
|
|
|
tableBodyEl?.addEventListener("click", (evt) => {
|
|
const btn = evt.target.closest("[data-action]");
|
|
if (!btn) return;
|
|
const id = btn.dataset.id;
|
|
const action = btn.dataset.action;
|
|
if (action === "design") openDesignerFor(id, { edit: true });
|
|
else if (action === "edit") openEditDashboardDialog(id);
|
|
else if (action === "delete") deleteDashboard(id);
|
|
});
|
|
|
|
el("dashboardBackToListBtn")?.addEventListener("click", () => {
|
|
setView("list");
|
|
window.NavApp?.syncDashboardSection?.("dashboard-list");
|
|
});
|
|
|
|
el("dashboardCreateForm")?.addEventListener("submit", (evt) => {
|
|
evt.preventDefault();
|
|
const name = normalizeStr(el("dashboardCreateName").value);
|
|
if (!name) return;
|
|
const user = currentUser();
|
|
const dashboard = {
|
|
id: newId("dashboard"),
|
|
name,
|
|
createdBy: user?.group_name || user?.display_name || user?.username || "—",
|
|
createdByUser: user?.username || null,
|
|
isDefault: false,
|
|
editGroups: readPermissionsChecklist(el("dashboardCreatePermissions")),
|
|
widgets: [],
|
|
};
|
|
store.dashboards.push(dashboard);
|
|
store.activeDashboardId = dashboard.id;
|
|
persistStore();
|
|
refreshNav();
|
|
openDesignerFor(dashboard.id);
|
|
});
|
|
|
|
el("dashboardEditForm")?.addEventListener("submit", (evt) => {
|
|
evt.preventDefault();
|
|
const id = el("dashboardEditId").value;
|
|
const dashboard = store.dashboards.find((d) => d.id === id);
|
|
if (!dashboard || !dashboardCanEdit(dashboard)) return;
|
|
dashboard.name = normalizeStr(el("dashboardEditName").value);
|
|
dashboard.editGroups = readPermissionsChecklist(el("dashboardEditPermissions"));
|
|
persistStore();
|
|
editDialogEl?.close();
|
|
refreshNav();
|
|
if (store.view === "list") renderListUI();
|
|
else renderDesignerChrome();
|
|
});
|
|
|
|
addTypeEl?.addEventListener("change", () => fillTypeFields(addFieldsEl, addTypeEl.value));
|
|
|
|
el("dashboardAddWidgetForm")?.addEventListener("submit", (evt) => {
|
|
evt.preventDefault();
|
|
const db = activeDashboard();
|
|
if (!db) return;
|
|
const type = addTypeEl.value;
|
|
const fields = readFields(addFieldsEl);
|
|
const widget = normalizeWidget({ id: newId("w"), type, ...fields, w: DEFAULT_W, h: DEFAULT_H });
|
|
const spot = findFreeGridSpot(db.widgets, widget.w, widget.h);
|
|
widget.col = spot.col;
|
|
widget.row = spot.row;
|
|
db.widgets.push(widget);
|
|
persistStore();
|
|
addDialogEl.close();
|
|
renderDashboard();
|
|
});
|
|
|
|
el("dashboardEditWidgetForm")?.addEventListener("submit", (evt) => {
|
|
evt.preventDefault();
|
|
const id = editWidgetIdEl.value;
|
|
const widget = activeWidgets().find((w) => w.id === id);
|
|
if (!widget) return;
|
|
Object.assign(widget, readFields(editFieldsEl));
|
|
persistStore();
|
|
editWidgetDialogEl.close();
|
|
renderDashboard();
|
|
});
|
|
|
|
el("dashboardDeleteWidgetBtn")?.addEventListener("click", () => deleteWidget(editWidgetIdEl.value));
|
|
|
|
editModeBtnEl?.addEventListener("click", async () => {
|
|
if (!dashboardCanEdit(activeDashboard())) return;
|
|
if (store.editMode) {
|
|
clearTimeout(persistTimer);
|
|
await syncStoreToBackend();
|
|
}
|
|
store.editMode = !store.editMode;
|
|
if (store.editMode) setWidgetTab("missions");
|
|
renderDashboard();
|
|
});
|
|
|
|
saveBtnEl?.addEventListener("click", async () => {
|
|
clearTimeout(persistTimer);
|
|
await syncStoreToBackend();
|
|
saveBtnEl.classList.add("is-saved");
|
|
saveBtnEl.textContent = t("dashboard.designer.saved");
|
|
setTimeout(() => {
|
|
saveBtnEl.classList.remove("is-saved");
|
|
saveBtnEl.textContent = t("dashboard.designer.save");
|
|
}, 1600);
|
|
});
|
|
|
|
designerToolbarEl?.addEventListener("click", (evt) => {
|
|
const tabBtn = evt.target.closest("[data-widget-tab]");
|
|
if (tabBtn) {
|
|
setWidgetTab(tabBtn.dataset.widgetTab);
|
|
return;
|
|
}
|
|
const btn = evt.target.closest("[data-add-widget]");
|
|
if (!btn) return;
|
|
addWidget(btn.dataset.addWidget);
|
|
});
|
|
}
|
|
|
|
function startDashboardPoll() {
|
|
if (window.AuthApp && !window.AuthApp.isReady()) return;
|
|
if (store.view !== "designer") return;
|
|
stopDashboardPoll();
|
|
missions()?.refreshQueue?.();
|
|
store.queueUnsub = missions()?.onQueueUpdate?.(() => refreshDynamicWidgets());
|
|
missions()?.startQueuePoll?.();
|
|
store.pollActive = true;
|
|
if (hasMapWidget()) {
|
|
void fetchRobotPose(true);
|
|
store.mapPollTimer = setInterval(() => {
|
|
void fetchRobotPose(true).then(() => refreshMapWidgets());
|
|
}, 2000);
|
|
}
|
|
}
|
|
|
|
function stopDashboardPoll() {
|
|
if (store.pollActive) {
|
|
missions()?.stopQueuePoll?.();
|
|
store.pollActive = false;
|
|
}
|
|
if (store.queueUnsub) {
|
|
store.queueUnsub();
|
|
store.queueUnsub = null;
|
|
}
|
|
if (store.mapPollTimer) {
|
|
clearInterval(store.mapPollTimer);
|
|
store.mapPollTimer = null;
|
|
}
|
|
}
|
|
|
|
async function init() {
|
|
await loadStoreFromBackend();
|
|
await loadUserGroups();
|
|
bindEvents();
|
|
setView("list");
|
|
}
|
|
|
|
window.DashboardApp = {
|
|
init,
|
|
getNavItems,
|
|
handleNav,
|
|
onPageShow() {
|
|
if (store.view === "designer") {
|
|
syncDesignerEditMode();
|
|
renderDesignerChrome();
|
|
renderDashboard();
|
|
startDashboardPoll();
|
|
} else {
|
|
renderListUI();
|
|
}
|
|
},
|
|
onPageHide() {
|
|
stopDashboardPoll();
|
|
},
|
|
refresh() {
|
|
if (store.view === "list") renderListUI();
|
|
else renderDashboard();
|
|
},
|
|
};
|
|
|
|
async function boot() {
|
|
await init();
|
|
refreshNav();
|
|
}
|
|
|
|
window.addEventListener("lm:locale-change", () => {
|
|
if (store.view === "list") renderListUI();
|
|
else {
|
|
renderDesignerChrome();
|
|
renderDashboard();
|
|
}
|
|
refreshNav();
|
|
});
|
|
|
|
if (window.AuthApp?.isReady()) boot();
|
|
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
|
window.addEventListener("lm:auth-logout", stopDashboardPoll);
|
|
})();
|