API mission
This commit is contained in:
16
www/app.js
16
www/app.js
@@ -8,6 +8,7 @@ const navItemEls = Array.from(document.querySelectorAll(".navItem[data-page]"));
|
||||
const pageOverviewEl = el("pageOverview");
|
||||
const pageConfigEl = el("pageConfig");
|
||||
const pageMissionsEl = el("pageMissions");
|
||||
const pageIntegrationsEl = el("pageIntegrations");
|
||||
const contentEl = document.querySelector(".content");
|
||||
const contentRightEl = el("contentRight");
|
||||
const overviewBackendEl = el("overviewBackend");
|
||||
@@ -120,7 +121,7 @@ const state = {
|
||||
};
|
||||
|
||||
function setActivePage(page) {
|
||||
const valid = ["dashboard", "config", "missions"];
|
||||
const valid = ["dashboard", "config", "missions", "integrations"];
|
||||
let p = valid.includes(page) ? page : "config";
|
||||
if (page === "overview") p = "dashboard";
|
||||
navItemEls.forEach((a) => {
|
||||
@@ -129,23 +130,32 @@ function setActivePage(page) {
|
||||
if (on) a.setAttribute("aria-current", "page");
|
||||
else a.removeAttribute("aria-current");
|
||||
});
|
||||
const titles = { dashboard: "Dashboard", config: "Cấu Hình", missions: "Missions" };
|
||||
const titles = {
|
||||
dashboard: "Dashboard",
|
||||
config: "Cấu Hình",
|
||||
missions: "Missions",
|
||||
integrations: "Tích hợp",
|
||||
};
|
||||
if (pageTitleEl) pageTitleEl.textContent = titles[p] || "Cấu Hình";
|
||||
if (pageOverviewEl) pageOverviewEl.hidden = p !== "dashboard";
|
||||
if (pageConfigEl) pageConfigEl.hidden = p !== "config";
|
||||
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
|
||||
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
||||
if (configSplitterEl) configSplitterEl.hidden = p !== "config";
|
||||
if (contentRightEl) contentRightEl.hidden = p !== "config";
|
||||
if (contentEl) {
|
||||
contentEl.classList.toggle("content--dashboard", p === "dashboard");
|
||||
contentEl.classList.toggle("content--config", p === "config");
|
||||
contentEl.classList.toggle("content--missions", p === "missions");
|
||||
contentEl.classList.toggle("content--integrations", p === "integrations");
|
||||
}
|
||||
if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config";
|
||||
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
|
||||
else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide();
|
||||
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
||||
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
|
||||
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
|
||||
else if (window.IntegrationsApp?.onPageHide) window.IntegrationsApp.onPageHide();
|
||||
try {
|
||||
localStorage.setItem("activePage", p);
|
||||
} catch {
|
||||
@@ -164,7 +174,7 @@ function initNavigation() {
|
||||
let initial = "config";
|
||||
try {
|
||||
const saved = localStorage.getItem("activePage");
|
||||
if (saved === "dashboard" || saved === "overview" || saved === "config" || saved === "missions") {
|
||||
if (saved === "dashboard" || saved === "overview" || saved === "config" || saved === "missions" || saved === "integrations") {
|
||||
initial = saved === "overview" ? "dashboard" : saved;
|
||||
}
|
||||
} catch {
|
||||
|
||||
147
www/index.html
147
www/index.html
@@ -35,6 +35,10 @@
|
||||
<span class="navDot"></span>
|
||||
Missions
|
||||
</a>
|
||||
<a class="navItem" href="#" data-page="integrations">
|
||||
<span class="navDot"></span>
|
||||
Tích hợp
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebarFooter">
|
||||
@@ -587,6 +591,79 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page" id="pageIntegrations" data-page-content="integrations" hidden>
|
||||
<div class="integrationsPage">
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">Modbus trigger</div>
|
||||
<div class="cardSub">System → Triggers — coil 1001–2000 gắn mission_id. Thiết bị remote bật coil (Modbus TCP :5502) → mission vào queue.</div>
|
||||
</div>
|
||||
<button id="integrationAddTriggerBtn" type="button" class="btn primary">Thêm trigger</button>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div id="integrationTriggerEmpty" class="mutedNote">Chưa có trigger Modbus.</div>
|
||||
<div id="integrationTriggerList" class="missionList"></div>
|
||||
<div class="integrationCoilSection">
|
||||
<div class="integrationSectionLabel">Coil đã gán (bấm để mô phỏng rising edge)</div>
|
||||
<div id="integrationCoilGrid" class="integrationCoilGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">REST API — MiR v2.0.0</div>
|
||||
<div class="cardSub">Hệ thống bên ngoài POST mission vào queue qua REST.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody integrationApiBody">
|
||||
<div class="row rowWide">
|
||||
<label>Base URL</label>
|
||||
<div id="integrationApiBaseUrl" class="mono integrationCode">—</div>
|
||||
</div>
|
||||
<div class="integrationApiBlock">
|
||||
<div class="integrationSectionLabel">POST /mission_queue</div>
|
||||
<pre class="integrationPre">curl -X POST "<span class="integrationCurlHost">http://localhost:8080</span>/api/v2.0.0/mission_queue" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"mission_id":"<mission_id>","priority":0,"robot_id":"default"}'</pre>
|
||||
</div>
|
||||
<div class="integrationApiBlock">
|
||||
<div class="integrationSectionLabel">GET /mission_queue · GET /missions · GET /status</div>
|
||||
<pre class="integrationPre">GET /api/v2.0.0/mission_queue
|
||||
GET /api/v2.0.0/missions
|
||||
GET /api/v2.0.0/status</pre>
|
||||
</div>
|
||||
<div class="row rowWide integrationTestRow">
|
||||
<label for="integrationRestMission">Thử nhanh</label>
|
||||
<div class="integrationTestActions">
|
||||
<select id="integrationRestMission"></select>
|
||||
<button id="integrationRestTestBtn" type="button" class="btn subtle">POST queue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">MiRFleet — Lên lịch mission</div>
|
||||
<div class="cardSub">Ưu tiên, gán robot, chạy ASAP hoặc theo thời gian.</div>
|
||||
</div>
|
||||
<div class="integrationHeaderActions">
|
||||
<button id="integrationRefreshBtn" type="button" class="btn subtle">Tải lại</button>
|
||||
<button id="integrationAddScheduleBtn" type="button" class="btn primary">Thêm lịch</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div id="integrationScheduleEmpty" class="mutedNote">Chưa có lịch fleet.</div>
|
||||
<div id="integrationScheduleList" class="missionList"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="configSplitter" class="splitter" role="separator" aria-orientation="vertical" tabindex="0"></div>
|
||||
|
||||
<div class="contentRight" id="contentRight">
|
||||
@@ -773,8 +850,78 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="integrationAddTriggerDialog" class="missionDialog">
|
||||
<form id="integrationAddTriggerForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Modbus trigger</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddTriggerDialog" aria-label="Đóng">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<div class="row rowWide">
|
||||
<label for="integrationTriggerName">Tên trigger</label>
|
||||
<input id="integrationTriggerName" type="text" required placeholder="VD: PLC line 1 start" />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="integrationTriggerCoil">Coil ID</label>
|
||||
<input id="integrationTriggerCoil" type="number" min="1001" max="2000" value="1001" required />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="integrationTriggerMission">Mission</label>
|
||||
<select id="integrationTriggerMission" required></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionDialogFooter">
|
||||
<button type="button" class="btn subtle" data-close-dialog="integrationAddTriggerDialog">Hủy</button>
|
||||
<button type="submit" class="btn primary">Thêm</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="integrationAddScheduleDialog" class="missionDialog">
|
||||
<form id="integrationAddScheduleForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Lịch MiRFleet</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddScheduleDialog" aria-label="Đóng">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<div class="row rowWide">
|
||||
<label for="integrationScheduleName">Tên lịch</label>
|
||||
<input id="integrationScheduleName" type="text" required placeholder="VD: Ca sáng — đi dock" />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="integrationScheduleMission">Mission</label>
|
||||
<select id="integrationScheduleMission" required></select>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="integrationScheduleRobot">Robot</label>
|
||||
<select id="integrationScheduleRobot"></select>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="integrationSchedulePriority">Ưu tiên</label>
|
||||
<input id="integrationSchedulePriority" type="number" value="0" />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="integrationScheduleMode">Chế độ</label>
|
||||
<select id="integrationScheduleMode">
|
||||
<option value="asap">ASAP — vào queue ngay</option>
|
||||
<option value="scheduled">Scheduled — theo thời gian</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row rowWide" id="integrationScheduleStartAtRow" hidden>
|
||||
<label for="integrationScheduleStartAt">Thời gian bắt đầu</label>
|
||||
<input id="integrationScheduleStartAt" type="datetime-local" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionDialogFooter">
|
||||
<button type="button" class="btn subtle" data-close-dialog="integrationAddScheduleDialog">Hủy</button>
|
||||
<button type="submit" class="btn primary">Thêm</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/missions.js"></script>
|
||||
<script src="/dashboard.js"></script>
|
||||
<script src="/integrations.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
444
www/integrations.js
Normal file
444
www/integrations.js
Normal file
@@ -0,0 +1,444 @@
|
||||
(() => {
|
||||
const COIL_MIN = 1001;
|
||||
const COIL_MAX = 2000;
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const triggerListEl = el("integrationTriggerList");
|
||||
const triggerEmptyEl = el("integrationTriggerEmpty");
|
||||
const coilGridEl = el("integrationCoilGrid");
|
||||
const scheduleListEl = el("integrationScheduleList");
|
||||
const scheduleEmptyEl = el("integrationScheduleEmpty");
|
||||
const addTriggerDialogEl = el("integrationAddTriggerDialog");
|
||||
const addScheduleDialogEl = el("integrationAddScheduleDialog");
|
||||
const apiBaseUrlEl = el("integrationApiBaseUrl");
|
||||
|
||||
const store = {
|
||||
triggers: [],
|
||||
schedules: [],
|
||||
robots: [],
|
||||
coils: {},
|
||||
missions: [],
|
||||
pollTimer: null,
|
||||
};
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function missionName(id) {
|
||||
const m = store.missions.find((x) => x.id === id);
|
||||
return m ? m.name : id;
|
||||
}
|
||||
|
||||
function robotLabel(id) {
|
||||
const r = store.robots.find((x) => x.id === id);
|
||||
return r ? r.name || r.id : id || "default";
|
||||
}
|
||||
|
||||
async function apiJson(url, opts = {}) {
|
||||
const res = await fetch(url, opts);
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg = (data && data.error) || text || res.statusText;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function refreshMissions() {
|
||||
try {
|
||||
const data = await apiJson("/api/missions");
|
||||
store.missions = Array.isArray(data.missions) ? data.missions : [];
|
||||
} catch {
|
||||
store.missions = window.MissionsApp?.getMissions?.() || [];
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRobots() {
|
||||
try {
|
||||
const data = await apiJson("/api/fleet/robots");
|
||||
store.robots = Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
store.robots = [{ id: "default", name: "Robot chính" }];
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTriggers() {
|
||||
store.triggers = await apiJson("/api/triggers");
|
||||
if (!Array.isArray(store.triggers)) store.triggers = [];
|
||||
}
|
||||
|
||||
async function refreshSchedules() {
|
||||
store.schedules = await apiJson("/api/fleet/schedules");
|
||||
if (!Array.isArray(store.schedules)) store.schedules = [];
|
||||
}
|
||||
|
||||
async function refreshCoils() {
|
||||
store.coils = (await apiJson("/api/modbus/coils")) || {};
|
||||
}
|
||||
|
||||
function fillMissionSelect(selectEl, selected) {
|
||||
if (!selectEl) return;
|
||||
selectEl.innerHTML = "";
|
||||
if (!store.missions.length) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = "— Chưa có mission —";
|
||||
selectEl.appendChild(opt);
|
||||
return;
|
||||
}
|
||||
store.missions.forEach((m) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.name;
|
||||
if (m.id === selected) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function fillRobotSelect(selectEl, selected) {
|
||||
if (!selectEl) return;
|
||||
selectEl.innerHTML = "";
|
||||
store.robots.forEach((r) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = r.id;
|
||||
opt.textContent = r.name || r.id;
|
||||
if (r.id === selected) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
if (!store.robots.length) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "default";
|
||||
opt.textContent = "Robot chính";
|
||||
opt.selected = selected === "default";
|
||||
selectEl.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTriggers() {
|
||||
if (!triggerListEl) return;
|
||||
triggerListEl.innerHTML = "";
|
||||
if (triggerEmptyEl) triggerEmptyEl.hidden = store.triggers.length > 0;
|
||||
|
||||
store.triggers.forEach((t) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "missionListItem integrationRow";
|
||||
const coil = t.coil_id;
|
||||
const on = store.coils[String(coil)] === true;
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<div class="missionListItemTitle">${escapeHtml(t.name)}</div>
|
||||
<div class="missionListItemMeta">
|
||||
Coil <span class="mono">${coil}</span>
|
||||
→ ${escapeHtml(missionName(t.mission_id))}
|
||||
· ${t.enabled === false ? "Tắt" : "Bật"}
|
||||
· coil hiện tại: <span class="mono">${on ? "ON" : "OFF"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionListItemActions">
|
||||
<button type="button" class="btn subtle" data-fire-coil="${coil}">Kích hoạt</button>
|
||||
<button type="button" class="btn subtle danger" data-delete-trigger="${escapeHtml(t.id)}">Xóa</button>
|
||||
</div>`;
|
||||
triggerListEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCoilGrid() {
|
||||
if (!coilGridEl) return;
|
||||
const assigned = new Map(store.triggers.map((t) => [t.coil_id, t]));
|
||||
const chips = [];
|
||||
assigned.forEach((t, coilId) => {
|
||||
const on = store.coils[String(coilId)] === true;
|
||||
chips.push(
|
||||
`<button type="button" class="integrationCoilChip${on ? " on" : ""}" data-fire-coil="${coilId}" title="${escapeHtml(t.name)}">
|
||||
${coilId}
|
||||
</button>`
|
||||
);
|
||||
});
|
||||
coilGridEl.innerHTML =
|
||||
chips.length > 0
|
||||
? chips.join("")
|
||||
: `<span class="mutedNote">Chưa gán coil. Thêm trigger bên trên (1001–2000).</span>`;
|
||||
}
|
||||
|
||||
function formatScheduleTime(s) {
|
||||
if (!s.start_at) return s.start_mode === "scheduled" ? "—" : "Ngay (asap)";
|
||||
try {
|
||||
return new Date(s.start_at).toLocaleString("vi-VN");
|
||||
} catch {
|
||||
return String(s.start_at);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSchedules() {
|
||||
if (!scheduleListEl) return;
|
||||
scheduleListEl.innerHTML = "";
|
||||
if (scheduleEmptyEl) scheduleEmptyEl.hidden = store.schedules.length > 0;
|
||||
|
||||
store.schedules.forEach((s) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "missionListItem integrationRow";
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<div class="missionListItemTitle">${escapeHtml(s.name)}</div>
|
||||
<div class="missionListItemMeta">
|
||||
${escapeHtml(missionName(s.mission_id))}
|
||||
· robot: ${escapeHtml(robotLabel(s.robot_id))}
|
||||
· ưu tiên ${s.priority ?? 0}
|
||||
· ${s.start_mode === "scheduled" ? "Lên lịch" : "ASAP"}
|
||||
· ${formatScheduleTime(s)}
|
||||
· ${s.enabled === false ? "Tắt" : "Bật"}
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionListItemActions">
|
||||
<button type="button" class="btn subtle" data-run-schedule="${escapeHtml(s.id)}">Chạy ngay</button>
|
||||
<button type="button" class="btn subtle danger" data-delete-schedule="${escapeHtml(s.id)}">Xóa</button>
|
||||
</div>`;
|
||||
scheduleListEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function updateApiBaseUrl() {
|
||||
const origin = window.location.origin;
|
||||
if (apiBaseUrlEl) apiBaseUrlEl.textContent = `${origin}/api/v2.0.0`;
|
||||
document.querySelectorAll(".integrationCurlHost").forEach((node) => {
|
||||
node.textContent = origin;
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await refreshMissions();
|
||||
await Promise.all([refreshRobots(), refreshTriggers(), refreshSchedules(), refreshCoils()]);
|
||||
renderTriggers();
|
||||
renderCoilGrid();
|
||||
renderSchedules();
|
||||
updateApiBaseUrl();
|
||||
}
|
||||
|
||||
async function openAddTriggerDialog() {
|
||||
await refreshMissions();
|
||||
fillMissionSelect(el("integrationTriggerMission"));
|
||||
const coilInput = el("integrationTriggerCoil");
|
||||
if (coilInput) coilInput.value = "1001";
|
||||
el("integrationTriggerName").value = "";
|
||||
addTriggerDialogEl?.showModal();
|
||||
}
|
||||
|
||||
async function submitAddTrigger(evt) {
|
||||
evt.preventDefault();
|
||||
const name = el("integrationTriggerName").value.trim();
|
||||
const coil_id = Number(el("integrationTriggerCoil").value);
|
||||
const mission_id = el("integrationTriggerMission").value;
|
||||
if (!name || !mission_id) return;
|
||||
if (coil_id < COIL_MIN || coil_id > COIL_MAX) {
|
||||
alert(`Coil phải từ ${COIL_MIN} đến ${COIL_MAX}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiJson("/api/triggers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, coil_id, mission_id, enabled: true }),
|
||||
});
|
||||
addTriggerDialogEl?.close();
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
alert(err.message || "Không thêm được trigger");
|
||||
}
|
||||
}
|
||||
|
||||
async function openAddScheduleDialog() {
|
||||
await Promise.all([refreshMissions(), refreshRobots()]);
|
||||
fillMissionSelect(el("integrationScheduleMission"));
|
||||
fillRobotSelect(el("integrationScheduleRobot"), "default");
|
||||
el("integrationScheduleName").value = "";
|
||||
el("integrationSchedulePriority").value = "0";
|
||||
el("integrationScheduleMode").value = "asap";
|
||||
el("integrationScheduleStartAt").value = "";
|
||||
toggleScheduleStartAt();
|
||||
addScheduleDialogEl?.showModal();
|
||||
}
|
||||
|
||||
function toggleScheduleStartAt() {
|
||||
const mode = el("integrationScheduleMode")?.value || "asap";
|
||||
const row = el("integrationScheduleStartAtRow");
|
||||
if (row) row.hidden = mode !== "scheduled";
|
||||
}
|
||||
|
||||
async function submitAddSchedule(evt) {
|
||||
evt.preventDefault();
|
||||
const name = el("integrationScheduleName").value.trim();
|
||||
const mission_id = el("integrationScheduleMission").value;
|
||||
const robot_id = el("integrationScheduleRobot").value || "default";
|
||||
const priority = Number(el("integrationSchedulePriority").value) || 0;
|
||||
const start_mode = el("integrationScheduleMode").value || "asap";
|
||||
const startRaw = el("integrationScheduleStartAt").value;
|
||||
if (!name || !mission_id) return;
|
||||
const payload = { name, mission_id, robot_id, priority, start_mode, enabled: true };
|
||||
if (start_mode === "scheduled" && startRaw) {
|
||||
payload.start_at = new Date(startRaw).toISOString();
|
||||
}
|
||||
try {
|
||||
await apiJson("/api/fleet/schedules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
addScheduleDialogEl?.close();
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
alert(err.message || "Không thêm được lịch");
|
||||
}
|
||||
}
|
||||
|
||||
async function fireCoil(coilId) {
|
||||
try {
|
||||
await apiJson(`/api/modbus/coils/${coilId}/trigger`, { method: "POST" });
|
||||
await refreshCoils();
|
||||
renderTriggers();
|
||||
renderCoilGrid();
|
||||
} catch (err) {
|
||||
alert(err.message || "Không kích hoạt được coil");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTrigger(id) {
|
||||
if (!confirm("Xóa trigger Modbus này?")) return;
|
||||
try {
|
||||
await apiJson(`/api/triggers/${id}`, { method: "DELETE" });
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
alert(err.message || "Không xóa được");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSchedule(id) {
|
||||
if (!confirm("Xóa lịch fleet này?")) return;
|
||||
try {
|
||||
await apiJson(`/api/fleet/schedules/${id}`, { method: "DELETE" });
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
alert(err.message || "Không xóa được");
|
||||
}
|
||||
}
|
||||
|
||||
async function runSchedule(id) {
|
||||
try {
|
||||
await apiJson(`/api/fleet/schedules/${id}/run`, { method: "POST" });
|
||||
window.MissionsApp?.refreshQueue?.();
|
||||
} catch (err) {
|
||||
alert(err.message || "Không chạy được lịch");
|
||||
}
|
||||
}
|
||||
|
||||
async function testRestEnqueue() {
|
||||
const missionId = el("integrationRestMission")?.value;
|
||||
if (!missionId) {
|
||||
alert("Chọn mission để thử");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await apiJson("/api/v2.0.0/mission_queue", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mission_id: missionId, priority: 0 }),
|
||||
});
|
||||
alert(`Đã thêm vào queue — id ${data.id}`);
|
||||
window.MissionsApp?.refreshQueue?.();
|
||||
} catch (err) {
|
||||
alert(err.message || "POST thất bại");
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
el("integrationAddTriggerBtn")?.addEventListener("click", openAddTriggerDialog);
|
||||
el("integrationAddScheduleBtn")?.addEventListener("click", openAddScheduleDialog);
|
||||
el("integrationRefreshBtn")?.addEventListener("click", () => refreshAll());
|
||||
el("integrationAddTriggerForm")?.addEventListener("submit", submitAddTrigger);
|
||||
el("integrationAddScheduleForm")?.addEventListener("submit", submitAddSchedule);
|
||||
el("integrationScheduleMode")?.addEventListener("change", toggleScheduleStartAt);
|
||||
el("integrationRestTestBtn")?.addEventListener("click", testRestEnqueue);
|
||||
|
||||
triggerListEl?.addEventListener("click", (evt) => {
|
||||
const coilBtn = evt.target.closest("[data-fire-coil]");
|
||||
if (coilBtn) {
|
||||
fireCoil(Number(coilBtn.getAttribute("data-fire-coil")));
|
||||
return;
|
||||
}
|
||||
const delBtn = evt.target.closest("[data-delete-trigger]");
|
||||
if (delBtn) deleteTrigger(delBtn.getAttribute("data-delete-trigger"));
|
||||
});
|
||||
|
||||
coilGridEl?.addEventListener("click", (evt) => {
|
||||
const btn = evt.target.closest("[data-fire-coil]");
|
||||
if (btn) fireCoil(Number(btn.getAttribute("data-fire-coil")));
|
||||
});
|
||||
|
||||
scheduleListEl?.addEventListener("click", (evt) => {
|
||||
const runBtn = evt.target.closest("[data-run-schedule]");
|
||||
if (runBtn) {
|
||||
runSchedule(runBtn.getAttribute("data-run-schedule"));
|
||||
return;
|
||||
}
|
||||
const delBtn = evt.target.closest("[data-delete-schedule]");
|
||||
if (delBtn) deleteSchedule(delBtn.getAttribute("data-delete-schedule"));
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-close-dialog]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const id = btn.getAttribute("data-close-dialog");
|
||||
el(id)?.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
stopPoll();
|
||||
store.pollTimer = setInterval(async () => {
|
||||
try {
|
||||
await refreshCoils();
|
||||
renderTriggers();
|
||||
renderCoilGrid();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (store.pollTimer) {
|
||||
clearInterval(store.pollTimer);
|
||||
store.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onPageShow() {
|
||||
await refreshAll();
|
||||
fillMissionSelect(el("integrationRestMission"));
|
||||
startPoll();
|
||||
}
|
||||
|
||||
function init() {
|
||||
bindEvents();
|
||||
updateApiBaseUrl();
|
||||
}
|
||||
|
||||
window.IntegrationsApp = {
|
||||
init,
|
||||
onPageShow,
|
||||
onPageHide: stopPoll,
|
||||
refreshAll,
|
||||
};
|
||||
|
||||
init();
|
||||
})();
|
||||
@@ -173,7 +173,7 @@
|
||||
};
|
||||
}
|
||||
|
||||
function loadStore() {
|
||||
function loadStoreLocal() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
@@ -186,11 +186,49 @@
|
||||
ensureDefaultGroups();
|
||||
}
|
||||
|
||||
async function loadStoreFromBackend() {
|
||||
try {
|
||||
const res = await fetch("/api/missions");
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.missions)) store.missions = data.missions;
|
||||
if (Array.isArray(data.groups)) store.groups = data.groups;
|
||||
ensureDefaultGroups();
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ missions: store.missions, groups: store.groups })
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let persistTimer = null;
|
||||
|
||||
async function syncStoreToBackend() {
|
||||
try {
|
||||
await fetch("/api/missions", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ missions: store.missions, groups: store.groups }),
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function loadStore() {
|
||||
loadStoreLocal();
|
||||
}
|
||||
|
||||
function persistStore() {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ missions: store.missions, groups: store.groups })
|
||||
);
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = setTimeout(syncStoreToBackend, 400);
|
||||
}
|
||||
|
||||
function ensureDefaultGroups() {
|
||||
@@ -1253,8 +1291,9 @@
|
||||
el("missionQueueForm")?.addEventListener("submit", submitQueueDialog);
|
||||
}
|
||||
|
||||
function init() {
|
||||
async function init() {
|
||||
loadStore();
|
||||
await loadStoreFromBackend();
|
||||
bindEvents();
|
||||
renderMissionList();
|
||||
}
|
||||
|
||||
@@ -537,7 +537,8 @@ canvas {
|
||||
.viewHint { color: var(--muted); font-size: 12px; width: 100%; }
|
||||
.canvasWrap canvas.edit-footprint { cursor: crosshair; }
|
||||
|
||||
.content.content--missions {
|
||||
.content.content--missions,
|
||||
.content.content--integrations {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-width: 1100px;
|
||||
}
|
||||
@@ -960,3 +961,42 @@ canvas {
|
||||
.contentLeft { max-height: none; overflow: visible; }
|
||||
}
|
||||
|
||||
.integrationsPage { min-width: 0; width: 100%; display: grid; gap: 16px; }
|
||||
.integrationRow { cursor: default; }
|
||||
.integrationHeaderActions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.integrationCoilSection { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); }
|
||||
.integrationSectionLabel { font-size: 12px; font-weight: 600; color: var(--muted); margin-bottom: 8px; }
|
||||
.integrationCoilGrid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.integrationCoilChip {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.integrationCoilChip.on {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--panel2));
|
||||
}
|
||||
.integrationCoilChip:hover { border-color: var(--accent); }
|
||||
.integrationApiBody { display: grid; gap: 14px; }
|
||||
.integrationApiBlock { display: grid; gap: 6px; }
|
||||
.integrationPre {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel2);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.integrationCode { font-size: 13px; word-break: break-all; }
|
||||
.integrationTestRow .integrationTestActions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.integrationTestRow select { min-width: 220px; }
|
||||
|
||||
Reference in New Issue
Block a user