update docker
This commit is contained in:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
build/
|
||||
.git/
|
||||
.vscode/
|
||||
*.md
|
||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@@ -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"]
|
||||
45
README.md
45
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
|
||||
```
|
||||
|
||||
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -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
|
||||
21
scripts/docker-htop.sh
Executable file
21
scripts/docker-htop.sh
Executable file
@@ -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
|
||||
21
scripts/docker-lib.sh
Normal file
21
scripts/docker-lib.sh
Normal file
@@ -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"
|
||||
}
|
||||
44
scripts/docker-stats.sh
Executable file
44
scripts/docker-stats.sh
Executable file
@@ -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"
|
||||
20
scripts/docker-up.sh
Executable file
20
scripts/docker-up.sh
Executable file
@@ -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
|
||||
128
www/app.js
128
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 {
|
||||
|
||||
@@ -50,6 +50,51 @@
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<div class="page" id="pageOverview" data-page-content="overview" hidden>
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">Phần mềm</div>
|
||||
<div class="cardSub">Thông tin phiên bản và trạng thái backend.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div class="row rowWide">
|
||||
<label>Backend</label>
|
||||
<div id="overviewBackend" class="mutedNote">—</div>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label>Dữ liệu</label>
|
||||
<div class="mutedNote">`data/state.json` (catalog) + `data/models/{id}.json` (layout)</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">Cấu hình đang active</div>
|
||||
<div class="cardSub">Tóm tắt layout đang mở.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div class="row rowWide">
|
||||
<label>Layout</label>
|
||||
<div id="overviewActiveLayout" class="mutedNote">—</div>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label>Model robot</label>
|
||||
<div id="overviewActiveModel" class="mutedNote">—</div>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label>LiDAR / IMU</label>
|
||||
<div id="overviewActiveSensors" class="mutedNote">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page" id="pageConfig" data-page-content="config">
|
||||
<div class="contentLeft">
|
||||
<section class="card" id="layoutManagerCard">
|
||||
<div class="cardHeader">
|
||||
@@ -462,6 +507,11 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="configSplitter" class="splitter" role="separator" aria-orientation="vertical" tabindex="0"></div>
|
||||
|
||||
<div class="contentRight">
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
@@ -482,6 +532,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user