From 8c111f2406c58584c8d73baa4507a46eb1b8fc57 Mon Sep 17 00:00:00 2001 From: HiepLM Date: Sat, 13 Jun 2026 10:17:26 +0700 Subject: [PATCH] update docker --- .dockerignore | 4 ++ Dockerfile | 39 ++++++++++++ README.md | 45 ++++++++++++++ docker-compose.yml | 14 +++++ scripts/docker-htop.sh | 21 +++++++ scripts/docker-lib.sh | 21 +++++++ scripts/docker-stats.sh | 44 ++++++++++++++ scripts/docker-up.sh | 20 +++++++ www/app.js | 128 ++++++++++++++++++++++++++++++++++++++++ www/index.html | 85 ++++++++++++++++++++------ www/style.css | 29 ++++++++- 11 files changed, 431 insertions(+), 19 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100755 scripts/docker-htop.sh create mode 100644 scripts/docker-lib.sh create mode 100755 scripts/docker-stats.sh create mode 100755 scripts/docker-up.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c212222 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +build/ +.git/ +.vscode/ +*.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d66fe6a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM ubuntu:20.04 AS build + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + cmake \ + git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +COPY CMakeLists.txt ./ +COPY src ./src + +RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \ + && cmake --build build -j"$(nproc)" + +FROM ubuntu:20.04 AS runtime + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + htop \ + procps \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=build /src/build/lidar_manager_web /app/lidar_manager_web +COPY www ./www + +RUN mkdir -p data/models + +EXPOSE 8080 + +ENTRYPOINT ["/app/lidar_manager_web"] +CMD ["8080", "/app/www", "/app/data/state.json"] diff --git a/README.md b/README.md index fd72d76..d186539 100644 --- a/README.md +++ b/README.md @@ -30,3 +30,48 @@ Hoặc chỉ định: Mở trình duyệt: `http://localhost:8080/` +## Docker (giới hạn 2 CPU, 4 GB RAM) + +Mô phỏng cấu hình controller tối thiểu SICK (Dual-Core, 4 GB) trên máy dev: + +```bash +cd /home/robotics/RD/Test3 +sudo docker compose up --build -d +# hoặc: sudo ./scripts/docker-up.sh +``` + +Kiểm tra giới hạn: + +```bash +sudo ./scripts/docker-stats.sh +``` + +Dừng: + +```bash +sudo docker compose down +``` + +Dữ liệu layout vẫn lưu tại `data/` trên host (volume mount). + +Kiểm tra tài nguyên trong container: + +```bash +# Vào shell container +sudo docker exec -it lidar-manager-limited bash +# hoặc: sudo ./scripts/docker-shell.sh + +# Trong container, thử: +htop # CPU/RAM (q để thoát) +free -h # RAM +nproc # số CPU nhìn thấy +ps aux # process +cat /proc/meminfo | head +``` + +```bash +# htop / stats từ ngoài (không cần vào shell) +sudo ./scripts/docker-htop.sh +sudo ./scripts/docker-stats.sh +``` + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3aa080b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + lidar-manager: + build: . + image: lidar-manager-web:test3 + container_name: lidar-manager-limited + ports: + - "8080:8080" + volumes: + - ./data:/app/data + restart: unless-stopped + # Giới hạn gần spec SICK controller: Dual-Core, 4 GB RAM + cpus: 2 + mem_limit: 4g + memswap_limit: 4g diff --git a/scripts/docker-htop.sh b/scripts/docker-htop.sh new file mode 100755 index 0000000..89a69be --- /dev/null +++ b/scripts/docker-htop.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +# shellcheck source=docker-lib.sh +source "$ROOT/scripts/docker-lib.sh" + +docker_cmd + +if ! "${DOCKER[@]}" ps --format '{{.Names}}' | grep -qx 'lidar-manager-limited'; then + echo "Container chưa chạy. Khởi động: sudo docker compose up -d" + exit 1 +fi + +echo "Giới hạn container:" +print_container_limits lidar-manager-limited + +echo +echo "Mở htop trong container (q để thoát)..." +exec "${DOCKER[@]}" exec -it lidar-manager-limited htop diff --git a/scripts/docker-lib.sh b/scripts/docker-lib.sh new file mode 100644 index 0000000..6423002 --- /dev/null +++ b/scripts/docker-lib.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +docker_cmd() { + if docker info >/dev/null 2>&1; then + DOCKER=(docker) + elif sudo -n docker info >/dev/null 2>&1; then + DOCKER=(sudo docker) + else + DOCKER=(sudo docker) + fi +} + +print_container_limits() { + local name="${1:-lidar-manager-limited}" + local nano mem cpus ram_mb + nano="$("${DOCKER[@]}" inspect -f '{{.HostConfig.NanoCpus}}' "$name")" + mem="$("${DOCKER[@]}" inspect -f '{{.HostConfig.Memory}}' "$name")" + cpus="$(awk "BEGIN { if ($nano > 0) printf \"%.2f\", $nano / 1000000000; else print \"unlimited\" }")" + ram_mb=$((mem / 1048576)) + echo " CPUs quota = ${cpus} core(s), RAM max = ${ram_mb} MB" +} diff --git a/scripts/docker-stats.sh b/scripts/docker-stats.sh new file mode 100755 index 0000000..1989f28 --- /dev/null +++ b/scripts/docker-stats.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=docker-lib.sh +source "$ROOT/scripts/docker-lib.sh" + +docker_cmd + +NAME="${1:-lidar-manager-limited}" + +if ! "${DOCKER[@]}" ps --format '{{.Names}}' | grep -qx "$NAME"; then + echo "Container '$NAME' không chạy." + exit 1 +fi + +echo "=== docker stats (live) ===" +"${DOCKER[@]}" stats --no-stream "$NAME" + +echo +echo "=== limits ===" +print_container_limits "$NAME" + +CID="$("${DOCKER[@]}" inspect -f '{{.Id}}' "$NAME")" +CG="/sys/fs/cgroup" + +echo +echo "=== cgroup (host) ===" +printf 'memory.usage = ' +cat "$CG/memory/docker/$CID/memory.usage_in_bytes" 2>/dev/null \ + || cat "$CG/memory/system.slice/docker-$CID.scope/memory.usage_in_bytes" 2>/dev/null \ + || echo "n/a" +printf ' bytes\n' + +printf 'cpu.cfs_quota / period = ' +cat "$CG/cpu,cpuacct/docker/$CID/cpu.cfs_quota_us" 2>/dev/null \ + || echo -n "n/a" +printf ' / ' +cat "$CG/cpu,cpuacct/docker/$CID/cpu.cfs_period_us" 2>/dev/null \ + || echo "n/a" + +echo +echo "=== processes in container ===" +"${DOCKER[@]}" top "$NAME" diff --git a/scripts/docker-up.sh b/scripts/docker-up.sh new file mode 100755 index 0000000..5c8ff5d --- /dev/null +++ b/scripts/docker-up.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +# shellcheck source=docker-lib.sh +source "$ROOT/scripts/docker-lib.sh" + +docker_cmd + +if ! docker info >/dev/null 2>&1 && ! sudo -n docker info >/dev/null 2>&1; then + echo "Cần quyền Docker. Chạy một trong hai cách:" + echo " sudo usermod -aG docker \$USER # rồi đăng nhập lại" + echo " sudo $0" +fi + +"${DOCKER[@]}" compose up --build -d +echo "Đã chạy: http://localhost:8080/" +echo -n "Giới hạn: " +print_container_limits lidar-manager-limited diff --git a/www/app.js b/www/app.js index 21a61e5..f8da904 100644 --- a/www/app.js +++ b/www/app.js @@ -3,6 +3,15 @@ const el = (id) => document.getElementById(id); const statusEl = el("status"); const listEl = el("lidarList"); const lidarFormHintEl = el("lidarFormHint"); +const pageTitleEl = document.querySelector(".pageTitle"); +const navItemEls = Array.from(document.querySelectorAll(".navItem[data-page]")); +const pageOverviewEl = el("pageOverview"); +const pageConfigEl = el("pageConfig"); +const overviewBackendEl = el("overviewBackend"); +const overviewActiveLayoutEl = el("overviewActiveLayout"); +const overviewActiveModelEl = el("overviewActiveModel"); +const overviewActiveSensorsEl = el("overviewActiveSensors"); +const configSplitterEl = el("configSplitter"); const canvasWrap = el("canvasWrap"); const robotModelEl = el("robotModel"); @@ -107,6 +116,111 @@ const state = { pendingFootprintClick: null, // { sx, sy } when Shift+click may add a vertex }; +function setActivePage(page) { + const p = page === "overview" ? "overview" : "config"; + navItemEls.forEach((a) => { + const on = (a.dataset.page || "") === p; + a.classList.toggle("active", on); + if (on) a.setAttribute("aria-current", "page"); + else a.removeAttribute("aria-current"); + }); + if (pageTitleEl) pageTitleEl.textContent = p === "overview" ? "Tổng quan" : "Cấu Hình"; + if (pageOverviewEl) pageOverviewEl.hidden = p !== "overview"; + if (pageConfigEl) pageConfigEl.hidden = p !== "config"; + try { + localStorage.setItem("activePage", p); + } catch { + /* ignore */ + } +} + +function initNavigation() { + navItemEls.forEach((a) => { + a.addEventListener("click", (evt) => { + evt.preventDefault(); + setActivePage(a.dataset.page || "config"); + }); + }); + // Restore last page, default to config (màn hình chính). + let initial = "config"; + try { + const saved = localStorage.getItem("activePage"); + if (saved === "overview" || saved === "config") initial = saved; + } catch { + /* ignore */ + } + setActivePage(initial); +} + +function setLeftPaneWidth(px) { + const v = Math.round(clamp(Number(px), 320, 720)); + document.documentElement.style.setProperty("--leftPaneW", `${v}px`); + try { + localStorage.setItem("leftPaneW", String(v)); + } catch { + /* ignore */ + } +} + +function initSplitPane() { + if (!configSplitterEl) return; + + try { + const saved = Number(localStorage.getItem("leftPaneW")); + if (Number.isFinite(saved) && saved > 0) setLeftPaneWidth(saved); + else setLeftPaneWidth(460); + } catch { + setLeftPaneWidth(460); + } + + let dragging = false; + let startX = 0; + let startW = 0; + + const onMove = (evt) => { + if (!dragging) return; + const x = evt.clientX ?? (evt.touches && evt.touches[0] ? evt.touches[0].clientX : startX); + setLeftPaneWidth(startW + (x - startX)); + }; + const onUp = () => { + if (!dragging) return; + dragging = false; + configSplitterEl.classList.remove("dragging"); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + window.removeEventListener("touchmove", onMove); + window.removeEventListener("touchend", onUp); + }; + + configSplitterEl.addEventListener("mousedown", (evt) => { + evt.preventDefault(); + dragging = true; + configSplitterEl.classList.add("dragging"); + startX = evt.clientX; + startW = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--leftPaneW")) || 460; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }); + + configSplitterEl.addEventListener("touchstart", (evt) => { + if (!evt.touches || !evt.touches[0]) return; + dragging = true; + configSplitterEl.classList.add("dragging"); + startX = evt.touches[0].clientX; + startW = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--leftPaneW")) || 460; + window.addEventListener("touchmove", onMove, { passive: true }); + window.addEventListener("touchend", onUp); + }, { passive: false }); + + // Keyboard resize (focus splitter, use arrows) + configSplitterEl.addEventListener("keydown", (evt) => { + if (evt.key !== "ArrowLeft" && evt.key !== "ArrowRight") return; + evt.preventDefault(); + const cur = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--leftPaneW")) || 460; + setLeftPaneWidth(cur + (evt.key === "ArrowLeft" ? -20 : 20)); + }); +} + const DIFF_DEFAULTS = { frame_id: "base_footprint", wheel_separation_m: 1.0, @@ -2906,6 +3020,7 @@ async function loadAll() { loadAllInFlight = (async () => { const st = await api("/api/state"); + if (overviewBackendEl) overviewBackendEl.textContent = "OK"; state.activeLayoutId = st.active_layout_id || null; state.activeLayoutName = st.active_layout_name || ""; state.layoutCatalog = st.layouts || []; @@ -2980,6 +3095,16 @@ async function loadAll() { setSelectedRelText(); renderList(); renderImuList(); + if (overviewActiveLayoutEl) { + const name = state.activeLayoutName || state.activeLayoutId || "—"; + overviewActiveLayoutEl.textContent = name; + } + if (overviewActiveModelEl) { + overviewActiveModelEl.textContent = state.layout?.robot?.model || "diff"; + } + if (overviewActiveSensorsEl) { + overviewActiveSensorsEl.textContent = `${state.lidars.length} LiDAR • ${state.imus.length} IMU`; + } if (!state.viewInitialized) { fitViewToWorld(); state.viewInitialized = true; @@ -3203,6 +3328,8 @@ function initRobotModelPanelCollapse() { } initLayoutManagerEvents(); +initNavigation(); +initSplitPane(); initLidarForm(); initMotorWheelsEvents(); initBicycleMotorWheelsEvents(); @@ -3253,6 +3380,7 @@ saveLayoutBtn.addEventListener("click", async () => { setStatus("Sẵn sàng"); } catch (e) { const msg = String(e.message || e); + if (overviewBackendEl) overviewBackendEl.textContent = `Lỗi: ${msg}`; if (msg.includes("stack") || msg.includes("Maximum call")) { setStatus(`Lỗi JavaScript: ${msg}`); } else { diff --git a/www/index.html b/www/index.html index 5492223..4ae7026 100644 --- a/www/index.html +++ b/www/index.html @@ -50,6 +50,51 @@
+ + +
@@ -462,26 +507,32 @@
-
-
-
-
Bố trí trên robot
-
-
+
-
-
- + + +
+
+
+
+
Bố trí trên robot
+
-
-
Cuộn chuột: zoom • Shift + kéo: di chuyển vùng nhìn
-
-
Robot center:
-
Selected: none
-
Pose:
+ +
+
+ +
+
+
Cuộn chuột: zoom • Shift + kéo: di chuyển vùng nhìn
+
+
Robot center:
+
Selected: none
+
Pose:
+
-
-
+ +
diff --git a/www/style.css b/www/style.css index 1fd44ec..0422167 100644 --- a/www/style.css +++ b/www/style.css @@ -137,15 +137,38 @@ body { .content { padding: 18px; display: grid; - grid-template-columns: min(460px, 100%) 1fr; + grid-template-columns: var(--leftPaneW, 460px) 10px 1fr; gap: 16px; align-items: start; + height: 100%; + min-height: 0; } .contentLeft { display: flex; flex-direction: column; gap: 16px; min-width: 0; + max-height: 100%; + overflow: auto; + overscroll-behavior: contain; +} +.contentRight { + min-width: 0; + max-height: 100%; +} +.splitter { + align-self: stretch; + width: 10px; + margin: 0 -16px; /* overlap gap a bit for easier grab */ + cursor: col-resize; + border-radius: 10px; + background: rgba(15, 23, 42, 0.04); + border: 1px solid rgba(15, 23, 42, 0.08); +} +.splitter:hover, +.splitter.dragging { + background: rgba(37, 99, 235, 0.12); + border-color: rgba(37, 99, 235, 0.25); } .modelForm { display: grid; gap: 10px; } .modelParams { display: grid; gap: 10px; } @@ -519,6 +542,8 @@ canvas { .shell { grid-template-columns: 1fr; } .sidebar { position: relative; height: auto; } .body { grid-template-rows: auto 1fr; } - .content { grid-template-columns: 1fr; } + .content { grid-template-columns: 1fr; height: auto; } + .splitter { display: none; } + .contentLeft { max-height: none; overflow: visible; } }