Files
jack ab1515dffb refactor: config/apps 目录重组、文档重构、架构图收窄
- 中央:config/(prometheus,alertmanager,grafana)、apps/(tile-cache,topology-editor)
- 边缘:config/(vmagent,blackbox,targets)、apps/(onvif-exporter)
- env: TRAEFIK_PROVIDER、prometheus/env.example 详细说明
- 文档:README/doc 重构,EDGE_CACHE 合并到 EDGE_AGENT_CONFIG
- targets.csv 更新流程说明,ARCHITECTURE 图收窄

Made-with: Cursor
2026-02-28 22:05:43 -05:00

510 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>拓扑标注助手GPS → targets.csv</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
margin: 0;
padding: 16px;
}
h1 {
font-size: 18px;
margin-bottom: 8px;
}
label {
display: block;
margin-top: 8px;
font-size: 14px;
}
input,
select {
width: 100%;
padding: 6px 8px;
font-size: 14px;
box-sizing: border-box;
}
button {
margin-top: 12px;
padding: 8px 12px;
font-size: 14px;
}
.row {
margin-bottom: 8px;
}
.log {
margin-top: 12px;
font-size: 12px;
color: #555;
white-space: pre-wrap;
}
#map-section {
margin-top: 20px;
border: 1px solid #ccc;
border-radius: 8px;
overflow: hidden;
}
#map-section h2 {
font-size: 14px;
margin: 0;
padding: 8px 12px;
background: #f5f5f5;
}
#map-container {
width: 100%;
height: 300px;
background: #e8e8e8;
}
.map-tip {
font-size: 12px;
color: #666;
padding: 6px 12px;
margin: 0;
}
</style>
</head>
<body>
<h1>拓扑标注助手</h1>
<p style="font-size: 13px">
与 central 同机运行时:先<strong>上传</strong>本机 <code>targets.csv</code>,在页面上选设备、补 GPS 或新建后<strong>下载</strong> CSV 部署到边缘。与 edge-agent 同机挂载 config 时可直接读写 <code>config/targets.csv</code>
</p>
<div id="upload-download-section" class="row" style="margin-top:12px; padding:10px; background:#f9f9f9; border-radius:6px;">
<label>上传 / 下载 targets.csv</label>
<div style="display:flex; flex-wrap:wrap; gap:8px; align-items:center;">
<input type="file" id="csv-file" accept=".csv" style="width:auto; max-width:200px;" />
<button id="btn-upload">上传 CSV</button>
<a id="btn-download" href="/api/download" download="targets.csv" style="padding:8px 12px; font-size:14px; background:#0d6efd; color:#fff; text-decoration:none; border-radius:4px;">下载 targets.csv</a>
</div>
<p id="mode-hint" style="font-size:12px; color:#666; margin:8px 0 0 0;"></p>
</div>
<div class="row">
<label>选择已有设备(可选,推荐)</label>
<select id="existing">
<option value="">(新建设备)</option>
</select>
</div>
<p style="font-size: 13px">
若是新建设备:先选上面的「(新建设备)」,再填写下列字段。
</p>
<div class="row">
<label>设备类型 (type)</label>
<select id="type">
<option value="topology">topology哑设备/拓扑节点)</option>
<option value="ping">ping有 IP可探测</option>
<option value="onvif">onvif摄像头</option>
</select>
</div>
<div class="row">
<label>设备名称 (name唯一标识)</label>
<input id="name" placeholder="例如 core_sw_1 / camera_front" />
</div>
<div class="row">
<label>角色 (role)</label>
<select id="role">
<option value="core_switch">core_switch</option>
<option value="access_switch">access_switch</option>
<option value="camera">camera</option>
<option value="wireless_bridge">wireless_bridge</option>
<option value="media_converter">media_converter</option>
<option value="other">other</option>
</select>
</div>
<div class="row">
<label>上联设备名称 (parent可留空)</label>
<input id="parent" placeholder="例如 core_sw_1" />
</div>
<div class="row">
<label>上联链路类型 (uplink_type)</label>
<select id="uplink_type">
<option value="">(无)</option>
<option value="fiber">fiber光纤</option>
<option value="copper">copper铜缆</option>
<option value="wireless">wireless无线</option>
</select>
</div>
<div class="row">
<label>IP可选ping/onvif 用)</label>
<input id="ip" placeholder="192.168.x.x" />
</div>
<div class="row">
<label>位置描述 (location可选)</label>
<input id="location" placeholder="例如A栋3楼弱电井" />
</div>
<div class="row">
<label>经度 (lon)</label>
<input id="lon" readonly />
</div>
<div class="row">
<label>纬度 (lat)</label>
<input id="lat" readonly />
</div>
<button id="btn-gps">获取当前 GPS 坐标</button>
<button id="btn-save">保存到 targets.csv</button>
<div class="log" id="log"></div>
<section id="map-section">
<h2>地图校验</h2>
<div class="row">
<label>天地图 TK<a href="https://console.tianditu.gov.cn/" target="_blank" rel="noopener">申请密钥</a>,填一次即可)</label>
<input id="tianditu-tk" type="text" placeholder="请输入您的天地图 tk" />
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; align-items:center;">
<button id="btn-load-map">加载天地图(直接)</button>
<span id="cache-buttons" style="display:none;">
<button id="btn-load-map-cache">加载天地图(使用服务器缓存)</button>
</span>
</div>
<div id="map-container"></div>
<p class="map-tip">点击地图可修正坐标;有经纬度后会自动打点并居中。</p>
</section>
<script>
const logEl = document.getElementById("log");
const existingSelect = document.getElementById("existing");
let existingTargets = [];
let map = null;
let marker = null;
let accuracyCircle = null;
let lastGpsAccuracyM = null;
let mapSource = null;
let leafletMap = null;
let leafletMarker = null;
let cacheBaseUrl = "";
function log(msg) {
logEl.textContent = msg;
}
function getTk() {
return (document.getElementById("tianditu-tk").value || "").trim();
}
function loadTiandituMap() {
const tk = getTk();
if (!tk) {
log("请先填写天地图 TK 再加载地图。");
return;
}
try {
localStorage.setItem("tianditu_tk", tk);
} catch (e) {}
if (window.T && window.T.Map) {
initMap();
return;
}
log("正在加载天地图…");
const script = document.createElement("script");
script.src = "https://api.tianditu.gov.cn/api?v=4.0&tk=" + encodeURIComponent(tk);
script.onload = function () {
log("天地图加载成功,可点击地图修正坐标。");
initMap();
};
script.onerror = function () {
log("天地图加载失败,请检查 TK 或网络。");
};
document.head.appendChild(script);
}
function initMap() {
if (map && mapSource === "tianditu") return;
var T = window.T;
if (!T || !T.Map) return;
if (map) return;
mapSource = "tianditu";
map = new T.Map("map-container");
map.centerAndZoom(new T.LngLat(116.4, 39.9), 12);
map.addEventListener("click", function (e) {
var ll = e.lnglat;
var lat = Math.round(ll.lat * 1e6) / 1e6;
var lon = Math.round(ll.lng * 1e6) / 1e6;
document.getElementById("lat").value = lat;
document.getElementById("lon").value = lon;
updateMapMarker(lat, lon);
log("已从地图点击设置坐标lat=" + lat + ", lon=" + lon);
});
var latEl = document.getElementById("lat").value.trim();
var lonEl = document.getElementById("lon").value.trim();
if (latEl && lonEl) {
var lat = parseFloat(latEl), lon = parseFloat(lonEl);
if (!isNaN(lat) && !isNaN(lon)) updateMapMarker(lat, lon);
}
}
function loadLeafletThenInitCacheMap() {
if (window.L && window.L.map) {
initCacheMap();
return;
}
log("正在加载地图库…");
var link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
link.integrity = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
link.crossOrigin = "";
document.head.appendChild(link);
var script = document.createElement("script");
script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
script.integrity = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
script.crossOrigin = "";
script.onload = function () {
log("使用服务器缓存加载天地图,可点击地图修正坐标。");
initCacheMap();
};
script.onerror = function () { log("Leaflet 加载失败"); };
document.head.appendChild(script);
}
function initCacheMap() {
if (leafletMap) return;
if (!cacheBaseUrl) { log("未配置缓存服务"); return; }
document.getElementById("map-container").innerHTML = "";
map = null;
marker = null;
mapSource = "leaflet";
leafletMap = L.map("map-container").setView([39.9, 116.4], 12);
L.tileLayer(cacheBaseUrl + "/vec/{z}/{x}/{y}", { attribution: "© 天地图" }).addTo(leafletMap);
L.tileLayer(cacheBaseUrl + "/cva/{z}/{x}/{y}", { attribution: "" }).addTo(leafletMap);
leafletMap.on("click", function (e) {
var lat = Math.round(e.latlng.lat * 1e6) / 1e6;
var lon = Math.round(e.latlng.lng * 1e6) / 1e6;
document.getElementById("lat").value = lat;
document.getElementById("lon").value = lon;
updateMapMarker(lat, lon);
log("已从地图点击设置坐标lat=" + lat + ", lon=" + lon);
});
var latEl = document.getElementById("lat").value.trim();
var lonEl = document.getElementById("lon").value.trim();
if (latEl && lonEl) {
var lat = parseFloat(latEl), lon = parseFloat(lonEl);
if (!isNaN(lat) && !isNaN(lon)) updateMapMarker(lat, lon);
}
}
function updateMapMarker(lat, lon) {
if (isNaN(lat) || isNaN(lon)) return;
if (mapSource === "leaflet" && leafletMap) {
if (leafletMarker) leafletMap.removeLayer(leafletMarker);
leafletMarker = L.marker([lat, lon]).addTo(leafletMap);
leafletMap.setView([lat, lon], 17);
return;
}
if (window.T && map) {
var T = window.T;
if (marker) {
map.removeOverLay(marker);
marker = null;
}
if (accuracyCircle) {
map.removeOverLay(accuracyCircle);
accuracyCircle = null;
}
var lngLat = new T.LngLat(lon, lat);
marker = new T.Marker(lngLat);
map.addOverLay(marker);
map.panTo(lngLat);
map.setZoom(17);
if (lastGpsAccuracyM != null && lastGpsAccuracyM > 0 && T.Circle) {
try {
accuracyCircle = new T.Circle(lngLat, lastGpsAccuracyM, {
color: "#3388ff",
weight: 1,
opacity: 0.4,
});
map.addOverLay(accuracyCircle);
} catch (e) {}
}
}
}
function fillFormFromTarget(t) {
document.getElementById("type").value = t.type || "topology";
document.getElementById("name").value = t.name || "";
document.getElementById("role").value = t.role || "other";
document.getElementById("parent").value = t.parent || "";
document.getElementById("uplink_type").value = t.uplink_type || "";
document.getElementById("ip").value = t.ip || "";
document.getElementById("location").value = t.location || "";
document.getElementById("lat").value = t.lat || "";
document.getElementById("lon").value = t.lon || "";
if (t.lat && t.lon) {
var lat = parseFloat(t.lat), lon = parseFloat(t.lon);
if (!isNaN(lat) && !isNaN(lon)) updateMapMarker(lat, lon);
}
}
async function loadExisting() {
try {
const resp = await fetch("/api/targets");
if (!resp.ok) throw new Error("加载 targets.csv 失败");
existingTargets = await resp.json();
existingSelect.innerHTML =
'<option value="">(新建设备)</option>' +
existingTargets
.map(
(t) =>
`<option value="${(t.name || '').replace(/"/g, '&quot;')}">${(t.name || '')} (${t.type || ""} / ${t.role || ""})</option>`
)
.join("");
log("已加载现有设备列表,可从下拉框选择后补充 GPS。");
} catch (e) {
log("加载现有设备失败:" + e.message);
}
}
existingSelect.onchange = () => {
const name = existingSelect.value;
if (!name) {
const lat = document.getElementById("lat").value;
const lon = document.getElementById("lon").value;
fillFormFromTarget({
type: "topology",
role: "other",
lat,
lon,
});
return;
}
const t = existingTargets.find((r) => r.name === name);
if (!t) return;
fillFormFromTarget(t);
};
document.getElementById("btn-gps").onclick = () => {
if (!navigator.geolocation) {
log("当前浏览器不支持 Geolocation。");
return;
}
log("正在获取 GPS 坐标…");
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude, accuracy } = pos.coords;
document.getElementById("lat").value = latitude.toFixed(6);
document.getElementById("lon").value = longitude.toFixed(6);
lastGpsAccuracyM = accuracy != null ? accuracy : null;
updateMapMarker(latitude, longitude);
var acc = lastGpsAccuracyM != null ? ",精度约 " + Math.round(lastGpsAccuracyM) + " 米" : "";
log("获取成功lat=" + latitude + ", lon=" + longitude + acc + "。请在地图上核对位置,若有偏差可点击地图修正。");
},
(err) => {
var msg = err.message || "";
if (msg.indexOf("secure origins") !== -1 || msg.indexOf("secure context") !== -1) {
log("获取定位失败:当前页面不是安全来源,浏览器不允许获取定位。请使用 https:// 访问本页,或在同一台电脑上用 http://localhost:4080 打开。");
} else {
log("获取定位失败:" + msg);
}
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
}
);
};
document.getElementById("btn-load-map").onclick = loadTiandituMap;
document.getElementById("btn-load-map-cache").onclick = loadLeafletThenInitCacheMap;
document.getElementById("btn-upload").onclick = async () => {
var fileInput = document.getElementById("csv-file");
if (!fileInput.files || !fileInput.files[0]) {
log("请先选择要上传的 CSV 文件。");
return;
}
var form = new FormData();
form.append("file", fileInput.files[0]);
try {
log("正在上传…");
var r = await fetch("/api/upload", { method: "POST", body: form });
var d = await r.json();
if (!r.ok) throw new Error(d.error || "上传失败");
log("已上传,共 " + (d.rows || 0) + " 条。");
loadExisting();
} catch (e) {
log("上传失败:" + e.message);
}
};
(async function setModeHint() {
try {
var r = await fetch("/api/mode");
var d = await r.json();
var el = document.getElementById("mode-hint");
el.textContent = d.mode === "file" ? "当前为「文件模式」:直接读写边缘 config/targets.csv保存后自动执行脚本。" : "当前为「内存模式」:请先上传 targets.csv编辑后可下载到本机再部署到边缘。";
} catch (e) {}
})();
(async function setMapConfig() {
try {
var r = await fetch("/api/map-config");
var d = await r.json();
if (d.cacheBaseUrl) {
cacheBaseUrl = d.cacheBaseUrl;
document.getElementById("cache-buttons").style.display = "inline";
}
} catch (e) {}
})();
(function () {
try {
var saved = localStorage.getItem("tianditu_tk");
if (saved) document.getElementById("tianditu-tk").value = saved;
} catch (e) {}
})();
document.getElementById("btn-save").onclick = async () => {
const payload = {
type: document.getElementById("type").value,
ip: document.getElementById("ip").value.trim(),
name: document.getElementById("name").value.trim(),
role: document.getElementById("role").value,
parent: document.getElementById("parent").value.trim(),
uplink_type: document.getElementById("uplink_type").value,
location: document.getElementById("location").value.trim(),
lat: document.getElementById("lat").value.trim(),
lon: document.getElementById("lon").value.trim(),
};
if (!payload.name || !payload.type) {
log("type 与 name 为必填。");
return;
}
try {
log("正在保存并重建配置…");
const resp = await fetch("/api/targets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || "请求失败");
}
log("已写入 targets.csv并触发 update-configs.sh。");
loadExisting();
} catch (e) {
log("保存失败:" + e.message);
}
};
loadExisting();
</script>